diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
index 42c2b5898..d7c720d29 100644
--- a/.github/CONTRIBUTING.md
+++ b/.github/CONTRIBUTING.md
@@ -103,7 +103,7 @@ While we really do value your requests and implement many of them, not all featu
### Improving documentation
-Caddy's documentation is available at [https://caddyserver.com/docs](https://caddyserver.com/docs). If you would like to make a fix to the docs, feel free to contribute at the [caddyserver/website](https://github.com/caddyserver/website) repository!
+Caddy's documentation is available at [https://caddyserver.com/docs](https://caddyserver.com/docs). If you would like to make a fix to the docs, please submit an issue here describing the change to make.
Note that plugin documentation is not hosted by the Caddy website, other than basic usage examples. They are managed by the individual plugin authors, and you will have to contact them to change their documentation.
diff --git a/README.md b/README.md
index d8e05ec30..8655205b9 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,5 @@
-
+
Every Site on HTTPS
Caddy is a general-purpose HTTP/2 web server that serves HTTPS by default.
@@ -21,7 +21,7 @@
---
-Caddy is fast, easy to use, and makes you more productive.
+Caddy is a **production-ready** open-source web server that is fast, easy to use, and makes you more productive.
Available for Windows, Mac, Linux, BSD, Solaris, and [Android](https://github.com/mholt/caddy/wiki/Running-Caddy-on-Android).
@@ -41,31 +41,35 @@ Available for Windows, Mac, Linux, BSD, Solaris, and [Android](https://github.co
- **Automatic HTTPS** on by default (via [Let's Encrypt](https://letsencrypt.org))
- **HTTP/2** by default
- **Virtual hosting** so multiple sites just work
-- Experimental **QUIC support** for those that like speed
+- Experimental **QUIC support** for cutting-edge transmissions
- TLS session ticket **key rotation** for more secure connections
- **Extensible with plugins** because a convenient web server is a helpful one
- **Runs anywhere** with **no external dependencies** (not even libc)
-There's way more, too! [See all features built into Caddy.](https://caddyserver.com/features) On top of all those, Caddy does even more with plugins: choose which plugins you want at [download](https://caddyserver.com/download).
+[See a more complete list of features built into Caddy.](https://caddyserver.com/features) On top of all those, Caddy does even more with plugins: choose which plugins you want at [download](https://caddyserver.com/download).
+
+Altogether, Caddy can do things other web servers simply cannot do. Its features and plugins save you time and mistakes, and will cheer you up. Your Caddy instance takes care of the details for you!
## Install
-Caddy binaries have no dependencies and are available for every platform. Get Caddy any one of these ways:
+Caddy binaries have no dependencies and are available for every platform. Get Caddy either of these ways:
+
+- **[Download page](https://caddyserver.com/download)** (RECOMMENDED) allows you to customize your build in the browser
+- **[Latest release](https://github.com/mholt/caddy/releases/latest)** for pre-built, vanilla binaries
-- **[Download page](https://caddyserver.com/download)** allows you to
-customize your build in the browser
-- **[Latest release](https://github.com/mholt/caddy/releases/latest)** for
-pre-built, vanilla binaries
## Build
+
To build from source you need **[Git](https://git-scm.com/downloads)** and **[Go](https://golang.org/doc/install)** (1.9 or newer). Follow these instruction for fast building:
-- Get source `go get github.com/mholt/caddy/caddy` and then run `go get github.com/caddyserver/builds`
-- Now `cd` to `$GOPATH/src/github.com/mholt/caddy/caddy` and run `go run build.go`
+- Get the source with `go get github.com/mholt/caddy/caddy` and then run `go get github.com/caddyserver/builds`
+- Now `cd $GOPATH/src/github.com/mholt/caddy/caddy` and run `go run build.go`
Then make sure the `caddy` binary is in your PATH.
+To build for other platforms, use build.go with the `--goos` and `--goarch` flags.
+
## Quick Start
@@ -85,7 +89,7 @@ If the `caddy` binary has permission to bind to low ports and your domain name's
caddy -host example.com
```
-This command serves static files from the current directory over HTTPS. Certificates are automatically obtained and renewed for you!
+This command serves static files from the current directory over HTTPS. Certificates are automatically obtained and renewed for you! Caddy is also automatically configuring ports 80 and 443 for you, and redirecting HTTP to HTTPS. Cool, huh?
### Customizing your site
@@ -115,7 +119,7 @@ To host multiple sites and do more with the Caddyfile, please see the [Caddyfile
Sites with qualifying hostnames are served over [HTTPS by default](https://caddyserver.com/docs/automatic-https).
-Caddy has a command line interface. Run `caddy -h` to view basic help or see the [CLI documentation](https://caddyserver.com/docs/cli) for details.
+Caddy has a nice little command line interface. Run `caddy -h` to view basic help or see the [CLI documentation](https://caddyserver.com/docs/cli) for details.
## Running in Production
@@ -139,7 +143,7 @@ Please see our [contributing guidelines](https://github.com/mholt/caddy/blob/mas
We use GitHub issues and pull requests only for discussing bug reports and the development of specific changes. We welcome all other topics on the [forum](https://caddy.community)!
-If you want to contribute to the documentation, please submit pull requests to [caddyserver/website](https://github.com/caddyserver/website).
+If you want to contribute to the documentation, please [submit an issue](https://github.com/mholt/caddy/issues/new) describing the change that should be made.
Thanks for making Caddy -- and the Web -- better!
@@ -158,6 +162,6 @@ We thank them for their services. **If you want to help keep Caddy free, please
Caddy was born out of the need for a "batteries-included" web server that runs anywhere and doesn't have to take its configuration with it. Caddy took inspiration from [spark](https://github.com/rif/spark), [nginx](https://github.com/nginx/nginx), lighttpd,
[Websocketd](https://github.com/joewalnes/websocketd) and [Vagrant](https://www.vagrantup.com/), which provides a pleasant mixture of features from each of them.
-**The name "Caddy":** The name of the software is "Caddy", not "Caddy Server" or "CaddyServer". Please call it "Caddy" or, if you wish to clarify, "the Caddy web server". See [brand guidelines](https://caddyserver.com/brand).
+**The name "Caddy" is trademarked:** The name of the software is "Caddy", not "Caddy Server" or "CaddyServer". Please call it "Caddy" or, if you wish to clarify, "the Caddy web server". See [brand guidelines](https://caddyserver.com/brand). Caddy is a registered trademark of Light Code Labs, LLC.
*Author on Twitter: [@mholt6](https://twitter.com/mholt6)*
diff --git a/caddy.go b/caddy.go
index cc9dfee45..a29f05d79 100644
--- a/caddy.go
+++ b/caddy.go
@@ -802,7 +802,7 @@ func startServers(serverList []Server, inst *Instance, restartFds map[string]res
continue
}
if strings.Contains(err.Error(), "use of closed network connection") {
- // this error is normal when closing the listener
+ // this error is normal when closing the listener; see https://github.com/golang/go/issues/4373
continue
}
log.Println(err)
diff --git a/caddy/caddymain/run.go b/caddy/caddymain/run.go
index 1d9f29573..7e1d0da77 100644
--- a/caddy/caddymain/run.go
+++ b/caddy/caddymain/run.go
@@ -31,7 +31,7 @@ import (
"github.com/mholt/caddy"
"github.com/mholt/caddy/caddytls"
"github.com/mholt/caddy/telemetry"
- "github.com/xenolf/lego/acme"
+ "github.com/xenolf/lego/acmev2"
"gopkg.in/natefinch/lumberjack.v2"
_ "github.com/mholt/caddy/caddyhttp" // plug in the HTTP server type
@@ -43,7 +43,7 @@ func init() {
setVersion()
flag.BoolVar(&caddytls.Agreed, "agree", false, "Agree to the CA's Subscriber Agreement")
- flag.StringVar(&caddytls.DefaultCAUrl, "ca", "https://acme-v01.api.letsencrypt.org/directory", "URL to certificate authority's ACME server directory")
+ flag.StringVar(&caddytls.DefaultCAUrl, "ca", "https://acme-v02.api.letsencrypt.org/directory", "URL to certificate authority's ACME server directory")
flag.BoolVar(&caddytls.DisableHTTPChallenge, "disable-http-challenge", caddytls.DisableHTTPChallenge, "Disable the ACME HTTP challenge")
flag.BoolVar(&caddytls.DisableTLSSNIChallenge, "disable-tls-sni-challenge", caddytls.DisableTLSSNIChallenge, "Disable the ACME TLS-SNI challenge")
flag.StringVar(&disabledMetrics, "disabled-metrics", "", "Comma-separated list of telemetry metrics to disable")
diff --git a/caddyfile/parse.go b/caddyfile/parse.go
index 41eefb4c0..1c26a97f9 100644
--- a/caddyfile/parse.go
+++ b/caddyfile/parse.go
@@ -265,14 +265,19 @@ func (p *parser) doImport() error {
} else {
globPattern = importPattern
}
+ if strings.Count(globPattern, "*") > 1 || strings.Count(globPattern, "?") > 1 ||
+ (strings.Contains(globPattern, "[") && strings.Contains(globPattern, "]")) {
+ // See issue #2096 - a pattern with many glob expansions can hang for too long
+ return p.Errf("Glob pattern may only contain one wildcard (*), but has others: %s", globPattern)
+ }
matches, err = filepath.Glob(globPattern)
if err != nil {
return p.Errf("Failed to use import pattern %s: %v", importPattern, err)
}
if len(matches) == 0 {
- if strings.Contains(globPattern, "*") {
- log.Printf("[WARNING] No files matching import pattern: %s", importPattern)
+ if strings.ContainsAny(globPattern, "*?[]") {
+ log.Printf("[WARNING] No files matching import glob pattern: %s", importPattern)
} else {
return p.Errf("File to import not found: %s", importPattern)
}
@@ -443,7 +448,7 @@ func replaceEnvReferences(s, refStart, refEnd string) string {
index := strings.Index(s, refStart)
for index != -1 {
endIndex := strings.Index(s, refEnd)
- if endIndex != -1 {
+ if endIndex > index+len(refStart) {
ref := s[index : endIndex+len(refEnd)]
s = strings.Replace(s, ref, os.Getenv(ref[len(refStart):len(ref)-len(refEnd)]), -1)
} else {
diff --git a/caddyfile/parse_test.go b/caddyfile/parse_test.go
index f119f7c01..72994ced4 100644
--- a/caddyfile/parse_test.go
+++ b/caddyfile/parse_test.go
@@ -228,6 +228,17 @@ func TestParseOneAndImport(t *testing.T) {
{`""`, false, []string{}, map[string]int{}},
{``, false, []string{}, map[string]int{}},
+
+ // test cases found by fuzzing!
+ {`import }{$"`, true, []string{}, map[string]int{}},
+ {`import /*/*.txt`, true, []string{}, map[string]int{}},
+ {`import /???/?*?o`, true, []string{}, map[string]int{}},
+ {`import /??`, true, []string{}, map[string]int{}},
+ {`import /[a-z]`, true, []string{}, map[string]int{}},
+ {`import {$}`, true, []string{}, map[string]int{}},
+ {`import {%}`, true, []string{}, map[string]int{}},
+ {`import {$$}`, true, []string{}, map[string]int{}},
+ {`import {%%}`, true, []string{}, map[string]int{}},
} {
result, err := testParseOne(test.input)
diff --git a/caddyhttp/caddyhttp.go b/caddyhttp/caddyhttp.go
index 3c9c81ef0..d9d97e9d0 100644
--- a/caddyhttp/caddyhttp.go
+++ b/caddyhttp/caddyhttp.go
@@ -46,5 +46,4 @@ import (
_ "github.com/mholt/caddy/caddyhttp/timeouts"
_ "github.com/mholt/caddy/caddyhttp/websocket"
_ "github.com/mholt/caddy/onevent"
- _ "github.com/mholt/caddy/startupshutdown"
)
diff --git a/caddyhttp/caddyhttp_test.go b/caddyhttp/caddyhttp_test.go
index 56ec884e0..ca3b97e19 100644
--- a/caddyhttp/caddyhttp_test.go
+++ b/caddyhttp/caddyhttp_test.go
@@ -25,7 +25,7 @@ import (
// ensure that the standard plugins are in fact plugged in
// and registered properly; this is a quick/naive way to do it.
func TestStandardPlugins(t *testing.T) {
- numStandardPlugins := 33 // importing caddyhttp plugs in this many plugins
+ numStandardPlugins := 31 // importing caddyhttp plugs in this many plugins
s := caddy.DescribePlugins()
if got, want := strings.Count(s, "\n"), numStandardPlugins+5; got != want {
t.Errorf("Expected all standard plugins to be plugged in, got:\n%s", s)
diff --git a/caddyhttp/fastcgi/fastcgi.go b/caddyhttp/fastcgi/fastcgi.go
index 28ea55f9f..54eb4e36c 100644
--- a/caddyhttp/fastcgi/fastcgi.go
+++ b/caddyhttp/fastcgi/fastcgi.go
@@ -33,8 +33,11 @@ import (
"sync/atomic"
"time"
+ "crypto/tls"
+
"github.com/mholt/caddy"
"github.com/mholt/caddy/caddyhttp/httpserver"
+ "github.com/mholt/caddy/caddytls"
)
// Handler is a middleware type that can handle requests as a FastCGI client.
@@ -323,6 +326,19 @@ func (h Handler) buildEnv(r *http.Request, rule Rule, fpath string) (map[string]
// Some web apps rely on knowing HTTPS or not
if r.TLS != nil {
env["HTTPS"] = "on"
+ // and pass the protocol details in a manner compatible with apache's mod_ssl
+ // (which is why they have a SSL_ prefix and not TLS_).
+ v, ok := tlsProtocolStringToMap[r.TLS.Version]
+ if ok {
+ env["SSL_PROTOCOL"] = v
+ }
+ // and pass the cipher suite in a manner compatible with apache's mod_ssl
+ for k, v := range caddytls.SupportedCiphersMap {
+ if v == r.TLS.CipherSuite {
+ env["SSL_CIPHER"] = k
+ break
+ }
+ }
}
// Add env variables from config (with support for placeholders in values)
@@ -465,3 +481,11 @@ type LogError string
func (l LogError) Error() string {
return string(l)
}
+
+// Map of supported protocols to Apache ssl_mod format
+// Note that these are slightly different from SupportedProtocols in caddytls/config.go's
+var tlsProtocolStringToMap = map[uint16]string{
+ tls.VersionTLS10: "TLSv1",
+ tls.VersionTLS11: "TLSv1.1",
+ tls.VersionTLS12: "TLSv1.2",
+}
diff --git a/caddyhttp/httpserver/https.go b/caddyhttp/httpserver/https.go
index ae3c4e902..a037a86d0 100644
--- a/caddyhttp/httpserver/https.go
+++ b/caddyhttp/httpserver/https.go
@@ -100,8 +100,8 @@ func enableAutoHTTPS(configs []*SiteConfig, loadCertificates bool) error {
}
cfg.TLS.Enabled = true
cfg.Addr.Scheme = "https"
- if loadCertificates && caddytls.HostQualifies(cfg.Addr.Host) {
- _, err := cfg.TLS.CacheManagedCertificate(cfg.Addr.Host)
+ if loadCertificates && caddytls.HostQualifies(cfg.TLS.Hostname) {
+ _, err := cfg.TLS.CacheManagedCertificate(cfg.TLS.Hostname)
if err != nil {
return err
}
diff --git a/caddyhttp/httpserver/logger.go b/caddyhttp/httpserver/logger.go
index f8ec8b276..1fc262419 100644
--- a/caddyhttp/httpserver/logger.go
+++ b/caddyhttp/httpserver/logger.go
@@ -44,6 +44,7 @@ type Logger struct {
V4ipMask net.IPMask
V6ipMask net.IPMask
IPMaskExists bool
+ Exceptions []string
}
// NewTestLogger creates logger suitable for testing purposes
@@ -84,6 +85,17 @@ func (l Logger) MaskIP(ip string) string {
}
+// ShouldLog returns true if the path is not exempted from
+// being logged (i.e. it is not found in l.Exceptions).
+func (l Logger) ShouldLog(path string) bool {
+ for _, exc := range l.Exceptions {
+ if Path(path).Matches(exc) {
+ return false
+ }
+ }
+ return true
+}
+
// Attach binds logger Start and Close functions to
// controller's OnStartup and OnShutdown hooks.
func (l *Logger) Attach(controller *caddy.Controller) {
diff --git a/caddyhttp/httpserver/plugin.go b/caddyhttp/httpserver/plugin.go
index 69be4b618..ead28d796 100644
--- a/caddyhttp/httpserver/plugin.go
+++ b/caddyhttp/httpserver/plugin.go
@@ -15,6 +15,7 @@
package httpserver
import (
+ "crypto/tls"
"flag"
"fmt"
"log"
@@ -123,15 +124,17 @@ func (h *httpContext) InspectServerBlocks(sourceFile string, serverBlocks []cadd
// For each address in each server block, make a new config
for _, sb := range serverBlocks {
for _, key := range sb.Keys {
- key = strings.ToLower(key)
- if _, dup := h.keysToSiteConfigs[key]; dup {
- return serverBlocks, fmt.Errorf("duplicate site key: %s", key)
- }
addr, err := standardizeAddress(key)
if err != nil {
return serverBlocks, err
}
+ addr = addr.Normalize()
+ key = addr.Key()
+ if _, dup := h.keysToSiteConfigs[key]; dup {
+ return serverBlocks, fmt.Errorf("duplicate site key: %s", key)
+ }
+
// Fill in address components from command line so that middleware
// have access to the correct information during setup
if addr.Host == "" && Host != DefaultHost {
@@ -146,7 +149,7 @@ func (h *httpContext) InspectServerBlocks(sourceFile string, serverBlocks []cadd
if addrCopy.Port == "" && Port == DefaultPort {
addrCopy.Port = Port
}
- addrStr := strings.ToLower(addrCopy.String())
+ addrStr := addrCopy.String()
if otherSiteKey, dup := siteAddrs[addrStr]; dup {
err := fmt.Errorf("duplicate site address: %s", addrStr)
if (addrCopy.Host == Host && Host != DefaultHost) ||
@@ -218,6 +221,13 @@ func (h *httpContext) MakeServers() ([]caddy.Server, error) {
}
}
+ // Iterate each site configuration and make sure that:
+ // 1) TLS is disabled for explicitly-HTTP sites (necessary
+ // when an HTTP address shares a block containing tls)
+ // 2) if QUIC is enabled, TLS ClientAuth is not, because
+ // currently, QUIC does not support ClientAuth (TODO:
+ // revisit this when our QUIC implementation supports it)
+ // 3) if TLS ClientAuth is used, StrictHostMatching is on
var atLeastOneSiteLooksLikeProduction bool
for _, cfg := range h.siteConfigs {
// see if all the addresses (both sites and
@@ -254,6 +264,17 @@ func (h *httpContext) MakeServers() ([]caddy.Server, error) {
// instead of 443 because it doesn't know about TLS.
cfg.Addr.Port = HTTPSPort
}
+ if cfg.TLS.ClientAuth != tls.NoClientCert {
+ if QUIC {
+ return nil, fmt.Errorf("cannot enable TLS client authentication with QUIC, because QUIC does not yet support it")
+ }
+ // this must be enabled so that a client cannot connect
+ // using SNI for another site on this listener that
+ // does NOT require ClientAuth, and then send HTTP
+ // requests with the Host header of this site which DOES
+ // require client auth, thus bypassing it...
+ cfg.StrictHostMatching = true
+ }
}
// we must map (group) each config to a bind address
@@ -287,12 +308,22 @@ func (h *httpContext) MakeServers() ([]caddy.Server, error) {
return servers, nil
}
+// normalizedKey returns "normalized" key representation:
+// scheme and host names are lowered, everything else stays the same
+func normalizedKey(key string) string {
+ addr, err := standardizeAddress(key)
+ if err != nil {
+ return key
+ }
+ return addr.Normalize().Key()
+}
+
// GetConfig gets the SiteConfig that corresponds to c.
// If none exist (should only happen in tests), then a
// new, empty one will be created.
func GetConfig(c *caddy.Controller) *SiteConfig {
ctx := c.Context().(*httpContext)
- key := strings.ToLower(c.Key)
+ key := normalizedKey(c.Key)
if cfg, ok := ctx.keysToSiteConfigs[key]; ok {
return cfg
}
@@ -396,6 +427,43 @@ func (a Address) VHost() string {
return a.Original
}
+// Normalize normalizes URL: turn scheme and host names into lower case
+func (a Address) Normalize() Address {
+ path := a.Path
+ if !CaseSensitivePath {
+ path = strings.ToLower(path)
+ }
+ return Address{
+ Original: a.Original,
+ Scheme: strings.ToLower(a.Scheme),
+ Host: strings.ToLower(a.Host),
+ Port: a.Port,
+ Path: path,
+ }
+}
+
+// Key is similar to String, just replaces scheme and host values with modified values.
+// Unlike String it doesn't add anything default (scheme, port, etc)
+func (a Address) Key() string {
+ res := ""
+ if a.Scheme != "" {
+ res += a.Scheme + "://"
+ }
+ if a.Host != "" {
+ res += a.Host
+ }
+ if a.Port != "" {
+ if strings.HasPrefix(a.Original[len(res):], ":"+a.Port) {
+ // insert port only if the original has its own explicit port
+ res += ":" + a.Port
+ }
+ }
+ if a.Path != "" {
+ res += a.Path
+ }
+ return res
+}
+
// standardizeAddress parses an address string into a structured format with separate
// scheme, host, port, and path portions, as well as the original input string.
func standardizeAddress(str string) (Address, error) {
@@ -523,6 +591,7 @@ var directives = []string{
"startup", // TODO: Deprecate this directive
"shutdown", // TODO: Deprecate this directive
"on",
+ "supervisor", // github.com/lucaslorentz/caddy-supervisor
"request_id",
"realip", // github.com/captncraig/caddy-realip
"git", // github.com/abiosoft/caddy-git
@@ -538,13 +607,13 @@ var directives = []string{
"ext",
"gzip",
"header",
+ "geoip", // github.com/kodnaplakal/caddy-geoip
"errors",
"authz", // github.com/casbin/caddy-authz
"filter", // github.com/echocat/caddy-filter
"minify", // github.com/hacdias/caddy-minify
"ipfilter", // github.com/pyed/ipfilter
"ratelimit", // github.com/xuqingfeng/caddy-rate-limit
- "search", // github.com/pedronasser/caddy-search
"expires", // github.com/epicagency/caddy-expires
"forwardproxy", // github.com/caddyserver/forwardproxy
"basicauth",
diff --git a/caddyhttp/httpserver/plugin_test.go b/caddyhttp/httpserver/plugin_test.go
index f7b9cfc00..bf922dfc4 100644
--- a/caddyhttp/httpserver/plugin_test.go
+++ b/caddyhttp/httpserver/plugin_test.go
@@ -18,6 +18,10 @@ import (
"strings"
"testing"
+ "sort"
+
+ "fmt"
+
"github.com/mholt/caddy"
"github.com/mholt/caddy/caddyfile"
)
@@ -147,7 +151,20 @@ func TestInspectServerBlocksWithCustomDefaultPort(t *testing.T) {
if err != nil {
t.Fatalf("Didn't expect an error, but got: %v", err)
}
- addr := ctx.keysToSiteConfigs["localhost"].Addr
+ localhostKey := "localhost"
+ item, ok := ctx.keysToSiteConfigs[localhostKey]
+ if !ok {
+ availableKeys := make(sort.StringSlice, len(ctx.keysToSiteConfigs))
+ i := 0
+ for key := range ctx.keysToSiteConfigs {
+ availableKeys[i] = fmt.Sprintf("'%s'", key)
+ i++
+ }
+ availableKeys.Sort()
+ t.Errorf("`%s` not found within registered keys, only these are available: %s", localhostKey, strings.Join(availableKeys, ", "))
+ return
+ }
+ addr := item.Addr
if addr.Port != Port {
t.Errorf("Expected the port on the address to be set, but got: %#v", addr)
}
@@ -184,6 +201,64 @@ func TestInspectServerBlocksCaseInsensitiveKey(t *testing.T) {
}
}
+func TestKeyNormalization(t *testing.T) {
+ originalCaseSensitivePath := CaseSensitivePath
+ defer func() {
+ CaseSensitivePath = originalCaseSensitivePath
+ }()
+ CaseSensitivePath = true
+
+ caseSensitiveData := []struct {
+ orig string
+ res string
+ }{
+ {
+ orig: "HTTP://A/ABCDEF",
+ res: "http://a/ABCDEF",
+ },
+ {
+ orig: "A/ABCDEF",
+ res: "a/ABCDEF",
+ },
+ {
+ orig: "A:2015/Port",
+ res: "a:2015/Port",
+ },
+ }
+ for _, item := range caseSensitiveData {
+ v := normalizedKey(item.orig)
+ if v != item.res {
+ t.Errorf("Normalization of `%s` with CaseSensitivePath option set to true must be equal to `%s`, got `%s` instead", item.orig, item.res, v)
+ }
+ }
+
+ CaseSensitivePath = false
+ caseInsensitiveData := []struct {
+ orig string
+ res string
+ }{
+ {
+ orig: "HTTP://A/ABCDEF",
+ res: "http://a/abcdef",
+ },
+ {
+ orig: "A/ABCDEF",
+ res: "a/abcdef",
+ },
+ {
+ orig: "A:2015/Port",
+ res: "a:2015/port",
+ },
+ }
+ for _, item := range caseInsensitiveData {
+ v := normalizedKey(item.orig)
+ if v != item.res {
+ t.Errorf("Normalization of `%s` with CaseSensitivePath option set to false must be equal to `%s`, got `%s` instead", item.orig, item.res, v)
+ }
+ }
+
+}
+
func TestGetConfig(t *testing.T) {
// case insensitivity for key
con := caddy.NewTestController("http", "")
@@ -201,6 +276,14 @@ func TestGetConfig(t *testing.T) {
if cfg == cfg3 {
t.Errorf("Expected different configs using when key is different; got %p and %p", cfg, cfg3)
}
+
+ con.Key = "foo/foobar"
+ cfg4 := GetConfig(con)
+ con.Key = "foo/Foobar"
+ cfg5 := GetConfig(con)
+ if cfg4 == cfg5 {
+ t.Errorf("Expected different cases in path to differentiate keys in general")
+ }
}
func TestDirectivesList(t *testing.T) {
diff --git a/caddyhttp/httpserver/replacer.go b/caddyhttp/httpserver/replacer.go
index 371526bf6..05fd18950 100644
--- a/caddyhttp/httpserver/replacer.go
+++ b/caddyhttp/httpserver/replacer.go
@@ -29,6 +29,7 @@ import (
"time"
"github.com/mholt/caddy"
+ "github.com/mholt/caddy/caddytls"
)
// requestReplacer is a strings.Replacer which is used to
@@ -140,6 +141,14 @@ func canLogRequest(r *http.Request) bool {
return false
}
+// unescapeBraces finds escaped braces in s and returns
+// a string with those braces unescaped.
+func unescapeBraces(s string) string {
+ s = strings.Replace(s, "\\{", "{", -1)
+ s = strings.Replace(s, "\\}", "}", -1)
+ return s
+}
+
// Replace performs a replacement of values on s and returns
// the string with the replaced values.
func (r *replacer) Replace(s string) string {
@@ -149,32 +158,59 @@ func (r *replacer) Replace(s string) string {
}
result := ""
+Placeholders: // process each placeholder in sequence
for {
- idxStart := strings.Index(s, "{")
- if idxStart == -1 {
- // no placeholder anymore
- break
- }
- idxEnd := strings.Index(s[idxStart:], "}")
- if idxEnd == -1 {
- // unpaired placeholder
- break
- }
- idxEnd += idxStart
+ var idxStart, idxEnd int
- // get a replacement
- placeholder := s[idxStart : idxEnd+1]
+ idxOffset := 0
+ for { // find first unescaped opening brace
+ searchSpace := s[idxOffset:]
+ idxStart = strings.Index(searchSpace, "{")
+ if idxStart == -1 {
+ // no more placeholders
+ break Placeholders
+ }
+ if idxStart == 0 || searchSpace[idxStart-1] != '\\' {
+ // preceding character is not an escape
+ idxStart += idxOffset
+ break
+ }
+ // the brace we found was escaped
+ // search the rest of the string next
+ idxOffset += idxStart + 1
+ }
+
+ idxOffset = 0
+ for { // find first unescaped closing brace
+ searchSpace := s[idxStart+idxOffset:]
+ idxEnd = strings.Index(searchSpace, "}")
+ if idxEnd == -1 {
+ // unpaired placeholder
+ break Placeholders
+ }
+ if idxEnd == 0 || searchSpace[idxEnd-1] != '\\' {
+ // preceding character is not an escape
+ idxEnd += idxOffset + idxStart
+ break
+ }
+ // the brace we found was escaped
+ // search the rest of the string next
+ idxOffset += idxEnd + 1
+ }
+
+ // get a replacement for the unescaped placeholder
+ placeholder := unescapeBraces(s[idxStart : idxEnd+1])
replacement := r.getSubstitution(placeholder)
- // append prefix + replacement
- result += s[:idxStart] + replacement
+ // append unescaped prefix + replacement
+ result += strings.TrimPrefix(unescapeBraces(s[:idxStart]), "\\") + replacement
// strip out scanned parts
s = s[idxEnd+1:]
}
// append unscanned parts
- return result + s
+ return result + unescapeBraces(s)
}
func roundDuration(d time.Duration) time.Duration {
@@ -224,6 +260,16 @@ func (r *replacer) getSubstitution(key string) string {
}
}
}
+ // search response headers then
+ if r.responseRecorder != nil && key[1] == '<' {
+ want := key[2 : len(key)-1]
+ for key, values := range r.responseRecorder.Header() {
+ // Header placeholders (case-insensitive)
+ if strings.EqualFold(key, want) {
+ return strings.Join(values, ",")
+ }
+ }
+ }
// next check for cookies
if key[1] == '~' {
name := key[2 : len(key)-1]
@@ -365,12 +411,46 @@ func (r *replacer) getSubstitution(key string) string {
}
elapsedDuration := time.Since(r.responseRecorder.start)
return strconv.FormatInt(convertToMilliseconds(elapsedDuration), 10)
+ case "{tls_protocol}":
+ if r.request.TLS != nil {
+ for k, v := range caddytls.SupportedProtocols {
+ if v == r.request.TLS.Version {
+ return k
+ }
+ }
+ return "tls" // this should never happen, but guard in case
+ }
+ return r.emptyValue // because not using a secure channel
+ case "{tls_cipher}":
+ if r.request.TLS != nil {
+ for k, v := range caddytls.SupportedCiphersMap {
+ if v == r.request.TLS.CipherSuite {
+ return k
+ }
+ }
+ return "UNKNOWN" // this should never happen, but guard in case
+ }
+ return r.emptyValue
+ default:
+ // {labelN}
+ if strings.HasPrefix(key, "{label") {
+ nStr := key[6 : len(key)-1] // get the integer N in "{labelN}"
+ n, err := strconv.Atoi(nStr)
+ if err != nil || n < 1 {
+ return r.emptyValue
+ }
+ labels := strings.Split(r.request.Host, ".")
+ if n > len(labels) {
+ return r.emptyValue
+ }
+ return labels[n-1]
+ }
}
return r.emptyValue
}
-//convertToMilliseconds returns the number of milliseconds in the given duration
+// convertToMilliseconds returns the number of milliseconds in the given duration
func convertToMilliseconds(d time.Duration) int64 {
return d.Nanoseconds() / 1e6
}
diff --git a/caddyhttp/httpserver/replacer_test.go b/caddyhttp/httpserver/replacer_test.go
index 654028d60..fe1e67c61 100644
--- a/caddyhttp/httpserver/replacer_test.go
+++ b/caddyhttp/httpserver/replacer_test.go
@@ -53,7 +53,7 @@ func TestReplace(t *testing.T) {
recordRequest := NewResponseRecorder(w)
reader := strings.NewReader(`{"username": "dennis"}`)
- request, err := http.NewRequest("POST", "http://localhost/?foo=bar", reader)
+ request, err := http.NewRequest("POST", "http://localhost.local/?foo=bar", reader)
if err != nil {
t.Fatalf("Failed to make request: %v", err)
}
@@ -67,6 +67,9 @@ func TestReplace(t *testing.T) {
request.Header.Set("CustomAdd", "caddy")
request.Header.Set("Cookie", "foo=bar; taste=delicious")
+ // add some respons headers
+ recordRequest.Header().Set("Custom", "CustomResponseHeader")
+
hostname, err := os.Hostname()
if err != nil {
t.Fatalf("Failed to determine hostname: %v", err)
@@ -84,7 +87,7 @@ func TestReplace(t *testing.T) {
expect string
}{
{"This hostname is {hostname}", "This hostname is " + hostname},
- {"This host is {host}.", "This host is localhost."},
+ {"This host is {host}.", "This host is localhost.local."},
{"This request method is {method}.", "This request method is POST."},
{"The response status is {status}.", "The response status is 200."},
{"{when}", "02/Jan/2006:15:04:05 +0000"},
@@ -92,10 +95,13 @@ func TestReplace(t *testing.T) {
{"{when_unix}", "1136214252"},
{"The Custom header is {>Custom}.", "The Custom header is foobarbaz."},
{"The CustomAdd header is {>CustomAdd}.", "The CustomAdd header is caddy."},
- {"The request is {request}.", "The request is POST /?foo=bar HTTP/1.1\\r\\nHost: localhost\\r\\n" +
+ {"The Custom response header is {Custom placeholder", "Bad {>Custom placeholder"},
+ {"The request is {request}.", "The request is POST /?foo=bar HTTP/1.1\\r\\nHost: localhost.local\\r\\n" +
"Cookie: foo=bar; taste=delicious\\r\\nCustom: foobarbaz\\r\\nCustomadd: caddy\\r\\n" +
"Shorterval: 1\\r\\n\\r\\n."},
{"The cUsToM header is {>cUsToM}...", "The cUsToM header is foobarbaz..."},
+ {"The cUsToM response header is {Non-Existent}.", "The Non-Existent header is -."},
{"Bad {host placeholder...", "Bad {host placeholder..."},
{"Bad {>Custom placeholder", "Bad {>Custom placeholder"},
@@ -106,6 +112,9 @@ func TestReplace(t *testing.T) {
{"Query string is {query}", "Query string is foo=bar"},
{"Query string value for foo is {?foo}", "Query string value for foo is bar"},
{"Missing query string argument is {?missing}", "Missing query string argument is "},
+ {"{label1} {label2} {label3} {label4}", "localhost local - -"},
+ {"Label with missing number is {label} or {labelQQ}", "Label with missing number is - or -"},
+ {"\\{ 'hostname': '{hostname}' \\}", "{ 'hostname': '" + hostname + "' }"},
}
for _, c := range testCases {
@@ -138,6 +147,107 @@ func TestReplace(t *testing.T) {
}
}
+func BenchmarkReplace(b *testing.B) {
+ w := httptest.NewRecorder()
+ recordRequest := NewResponseRecorder(w)
+ reader := strings.NewReader(`{"username": "dennis"}`)
+
+ request, err := http.NewRequest("POST", "http://localhost/?foo=bar", reader)
+ if err != nil {
+ b.Fatalf("Failed to make request: %v", err)
+ }
+ ctx := context.WithValue(request.Context(), OriginalURLCtxKey, *request.URL)
+ request = request.WithContext(ctx)
+
+ request.Header.Set("Custom", "foobarbaz")
+ request.Header.Set("ShorterVal", "1")
+ repl := NewReplacer(request, recordRequest, "-")
+ // add some headers after creating replacer
+ request.Header.Set("CustomAdd", "caddy")
+ request.Header.Set("Cookie", "foo=bar; taste=delicious")
+
+ // add some respons headers
+ recordRequest.Header().Set("Custom", "CustomResponseHeader")
+
+ now = func() time.Time {
+ return time.Date(2006, 1, 2, 15, 4, 5, 02, time.FixedZone("hardcoded", -7))
+ }
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ repl.Replace("This hostname is {hostname}")
+ }
+}
+
+func BenchmarkReplaceEscaped(b *testing.B) {
+ w := httptest.NewRecorder()
+ recordRequest := NewResponseRecorder(w)
+ reader := strings.NewReader(`{"username": "dennis"}`)
+
+ request, err := http.NewRequest("POST", "http://localhost/?foo=bar", reader)
+ if err != nil {
+ b.Fatalf("Failed to make request: %v", err)
+ }
+ ctx := context.WithValue(request.Context(), OriginalURLCtxKey, *request.URL)
+ request = request.WithContext(ctx)
+
+ request.Header.Set("Custom", "foobarbaz")
+ request.Header.Set("ShorterVal", "1")
+ repl := NewReplacer(request, recordRequest, "-")
+ // add some headers after creating replacer
+ request.Header.Set("CustomAdd", "caddy")
+ request.Header.Set("Cookie", "foo=bar; taste=delicious")
+
+ // add some respons headers
+ recordRequest.Header().Set("Custom", "CustomResponseHeader")
+
+ now = func() time.Time {
+ return time.Date(2006, 1, 2, 15, 4, 5, 02, time.FixedZone("hardcoded", -7))
+ }
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ repl.Replace("\\{ 'hostname': '{hostname}' \\}")
+ }
+}
+
+func TestResponseRecorderNil(t *testing.T) {
+
+ reader := strings.NewReader(`{"username": "dennis"}`)
+
+ request, err := http.NewRequest("POST", "http://localhost/?foo=bar", reader)
+ if err != nil {
+ t.Fatalf("Failed to make request: %v", err)
+ }
+
+ request.Header.Set("Custom", "foobarbaz")
+ repl := NewReplacer(request, nil, "-")
+ // add some headers after creating replacer
+ request.Header.Set("CustomAdd", "caddy")
+ request.Header.Set("Cookie", "foo=bar; taste=delicious")
+
+ old := now
+ now = func() time.Time {
+ return time.Date(2006, 1, 2, 15, 4, 5, 02, time.FixedZone("hardcoded", -7))
+ }
+ defer func() {
+ now = old
+ }()
+ testCases := []struct {
+ template string
+ expect string
+ }{
+ {"The Custom response header is { timeout+errorMargin {
+ t.Errorf("Expected timeout ~ %v but got %v", timeout, took)
+ }
+}
+
func TestWebSocketReverseProxyNonHijackerPanic(t *testing.T) {
// Capture the expected panic
defer func() {
@@ -301,7 +326,7 @@ func TestWebSocketReverseProxyNonHijackerPanic(t *testing.T) {
defer wsNop.Close()
// Get proxy to use for the test
- p := newWebSocketTestProxy(wsNop.URL, false)
+ p := newWebSocketTestProxy(wsNop.URL, false, 30*time.Second)
// Create client request
r := httptest.NewRequest("GET", "/", nil)
@@ -331,7 +356,7 @@ func TestWebSocketReverseProxyBackendShutDown(t *testing.T) {
}()
// Get proxy to use for the test
- p := newWebSocketTestProxy(backend.URL, false)
+ p := newWebSocketTestProxy(backend.URL, false, 30*time.Second)
backendProxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p.ServeHTTP(w, r)
}))
@@ -360,7 +385,7 @@ func TestWebSocketReverseProxyServeHTTPHandler(t *testing.T) {
defer wsNop.Close()
// Get proxy to use for the test
- p := newWebSocketTestProxy(wsNop.URL, false)
+ p := newWebSocketTestProxy(wsNop.URL, false, 30*time.Second)
// Create client request
r := httptest.NewRequest("GET", "/", nil)
@@ -407,7 +432,7 @@ func TestWebSocketReverseProxyFromWSClient(t *testing.T) {
defer wsEcho.Close()
// Get proxy to use for the test
- p := newWebSocketTestProxy(wsEcho.URL, false)
+ p := newWebSocketTestProxy(wsEcho.URL, false, 30*time.Second)
// This is a full end-end test, so the proxy handler
// has to be part of a server listening on a port. Our
@@ -452,7 +477,7 @@ func TestWebSocketReverseProxyFromWSSClient(t *testing.T) {
}))
defer wsEcho.Close()
- p := newWebSocketTestProxy(wsEcho.URL, true)
+ p := newWebSocketTestProxy(wsEcho.URL, true, 30*time.Second)
echoProxy := newTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p.ServeHTTP(w, r)
@@ -528,7 +553,7 @@ func TestUnixSocketProxy(t *testing.T) {
defer ts.Close()
url := strings.Replace(ts.URL, "http://", "unix:", 1)
- p := newWebSocketTestProxy(url, false)
+ p := newWebSocketTestProxy(url, false, 30*time.Second)
echoProxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p.ServeHTTP(w, r)
@@ -686,7 +711,7 @@ func TestUpstreamHeadersUpdate(t *testing.T) {
}))
defer backend.Close()
- upstream := newFakeUpstream(backend.URL, false)
+ upstream := newFakeUpstream(backend.URL, false, 30*time.Second)
upstream.host.UpstreamHeaders = http.Header{
"Connection": {"{>Connection}"},
"Upgrade": {"{>Upgrade}"},
@@ -753,7 +778,7 @@ func TestDownstreamHeadersUpdate(t *testing.T) {
}))
defer backend.Close()
- upstream := newFakeUpstream(backend.URL, false)
+ upstream := newFakeUpstream(backend.URL, false, 30*time.Second)
upstream.host.DownstreamHeaders = http.Header{
"+Merge-Me": {"Merge-Value"},
"+Add-Me": {"Add-Value"},
@@ -893,7 +918,7 @@ func TestHostSimpleProxyNoHeaderForward(t *testing.T) {
// set up proxy
p := &Proxy{
Next: httpserver.EmptyNext, // prevents panic in some cases when test fails
- Upstreams: []Upstream{newFakeUpstream(backend.URL, false)},
+ Upstreams: []Upstream{newFakeUpstream(backend.URL, false, 30*time.Second)},
}
r := httptest.NewRequest("GET", "/", nil)
@@ -913,6 +938,67 @@ func TestHostSimpleProxyNoHeaderForward(t *testing.T) {
}
}
+func TestReverseProxyTransparentHeaders(t *testing.T) {
+ testCases := []struct {
+ name string
+ remoteAddr string
+ forwardedForHeader string
+ expected []string
+ }{
+ {"No header", "192.168.0.1:80", "", []string{"192.168.0.1"}},
+ {"Existing", "192.168.0.1:80", "1.1.1.1, 2.2.2.2", []string{"1.1.1.1, 2.2.2.2, 192.168.0.1"}},
+ }
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ testReverseProxyTransparentHeaders(t, tc.remoteAddr, tc.forwardedForHeader, tc.expected)
+ })
+ }
+}
+
+func testReverseProxyTransparentHeaders(t *testing.T, remoteAddr, forwardedForHeader string, expected []string) {
+ // Arrange
+ log.SetOutput(ioutil.Discard)
+ defer log.SetOutput(os.Stderr)
+
+ var actualHeaders http.Header
+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ actualHeaders = r.Header
+ }))
+ defer backend.Close()
+
+ config := "proxy / " + backend.URL + " {\n transparent \n}"
+
+ // make proxy
+ upstreams, err := NewStaticUpstreams(caddyfile.NewDispenser("Testfile", strings.NewReader(config)), "")
+ if err != nil {
+ t.Errorf("Expected no error. Got: %s", err.Error())
+ }
+
+ // set up proxy
+ p := &Proxy{
+ Next: httpserver.EmptyNext, // prevents panic in some cases when test fails
+ Upstreams: upstreams,
+ }
+
+ // create request and response recorder
+ r := httptest.NewRequest("GET", backend.URL, nil)
+ r.RemoteAddr = remoteAddr
+ if forwardedForHeader != "" {
+ r.Header.Set("X-Forwarded-For", forwardedForHeader)
+ }
+
+ w := httptest.NewRecorder()
+
+ // Act
+ p.ServeHTTP(w, r)
+
+ // Assert
+ if got := actualHeaders["X-Forwarded-For"]; !reflect.DeepEqual(got, expected) {
+ t.Errorf("Transparent proxy response does not contain expected %v header: expect %v, but got %v",
+ "X-Forwarded-For", expected, got)
+ }
+}
+
func TestHostHeaderReplacedUsingForward(t *testing.T) {
var requestHost string
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -921,7 +1007,7 @@ func TestHostHeaderReplacedUsingForward(t *testing.T) {
}))
defer backend.Close()
- upstream := newFakeUpstream(backend.URL, false)
+ upstream := newFakeUpstream(backend.URL, false, 30*time.Second)
proxyHostHeader := "test2.com"
upstream.host.UpstreamHeaders = http.Header{"Host": []string{proxyHostHeader}}
// set up proxy
@@ -943,11 +1029,22 @@ func TestHostHeaderReplacedUsingForward(t *testing.T) {
}
func TestBasicAuth(t *testing.T) {
- basicAuthTestcase(t, nil, nil)
- basicAuthTestcase(t, nil, url.UserPassword("username", "password"))
- basicAuthTestcase(t, url.UserPassword("usename", "password"), nil)
- basicAuthTestcase(t, url.UserPassword("unused", "unused"),
- url.UserPassword("username", "password"))
+ testCases := []struct {
+ name string
+ upstreamUser *url.Userinfo
+ clientUser *url.Userinfo
+ }{
+ {"Nil Both", nil, nil},
+ {"Nil Upstream User", nil, url.UserPassword("username", "password")},
+ {"Nil Client User", url.UserPassword("usename", "password"), nil},
+ {"Both Provided", url.UserPassword("unused", "unused"),
+ url.UserPassword("username", "password")},
+ }
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ basicAuthTestcase(t, tc.upstreamUser, tc.clientUser)
+ })
+ }
}
func basicAuthTestcase(t *testing.T, upstreamUser, clientUser *url.Userinfo) {
@@ -972,7 +1069,7 @@ func basicAuthTestcase(t *testing.T, upstreamUser, clientUser *url.Userinfo) {
p := &Proxy{
Next: httpserver.EmptyNext,
- Upstreams: []Upstream{newFakeUpstream(backURL.String(), false)},
+ Upstreams: []Upstream{newFakeUpstream(backURL.String(), false, 30*time.Second)},
}
r, err := http.NewRequest("GET", "/foo", nil)
if err != nil {
@@ -1107,7 +1204,7 @@ func TestProxyDirectorURL(t *testing.T) {
continue
}
- NewSingleHostReverseProxy(targetURL, c.without, 0).Director(req)
+ NewSingleHostReverseProxy(targetURL, c.without, 0, 30*time.Second).Director(req)
if expect, got := c.expectURL, req.URL.String(); expect != got {
t.Errorf("case %d url not equal: expect %q, but got %q",
i, expect, got)
@@ -1254,7 +1351,7 @@ func TestCancelRequest(t *testing.T) {
// set up proxy
p := &Proxy{
Next: httpserver.EmptyNext, // prevents panic in some cases when test fails
- Upstreams: []Upstream{newFakeUpstream(backend.URL, false)},
+ Upstreams: []Upstream{newFakeUpstream(backend.URL, false, 30*time.Second)},
}
// setup request with cancel ctx
@@ -1303,14 +1400,15 @@ func (r *noopReader) Read(b []byte) (int, error) {
return n, nil
}
-func newFakeUpstream(name string, insecure bool) *fakeUpstream {
+func newFakeUpstream(name string, insecure bool, timeout time.Duration) *fakeUpstream {
uri, _ := url.Parse(name)
u := &fakeUpstream{
- name: name,
- from: "/",
+ name: name,
+ from: "/",
+ timeout: timeout,
host: &UpstreamHost{
Name: name,
- ReverseProxy: NewSingleHostReverseProxy(uri, "", http.DefaultMaxIdleConnsPerHost),
+ ReverseProxy: NewSingleHostReverseProxy(uri, "", http.DefaultMaxIdleConnsPerHost, timeout),
},
}
if insecure {
@@ -1324,6 +1422,7 @@ type fakeUpstream struct {
host *UpstreamHost
from string
without string
+ timeout time.Duration
}
func (u *fakeUpstream) From() string {
@@ -1338,7 +1437,7 @@ func (u *fakeUpstream) Select(r *http.Request) *UpstreamHost {
}
u.host = &UpstreamHost{
Name: u.name,
- ReverseProxy: NewSingleHostReverseProxy(uri, u.without, http.DefaultMaxIdleConnsPerHost),
+ ReverseProxy: NewSingleHostReverseProxy(uri, u.without, http.DefaultMaxIdleConnsPerHost, u.GetTimeout()),
}
}
return u.host
@@ -1347,6 +1446,7 @@ func (u *fakeUpstream) Select(r *http.Request) *UpstreamHost {
func (u *fakeUpstream) AllowedPath(requestPath string) bool { return true }
func (u *fakeUpstream) GetTryDuration() time.Duration { return 1 * time.Second }
func (u *fakeUpstream) GetTryInterval() time.Duration { return 250 * time.Millisecond }
+func (u *fakeUpstream) GetTimeout() time.Duration { return u.timeout }
func (u *fakeUpstream) GetHostCount() int { return 1 }
func (u *fakeUpstream) Stop() error { return nil }
@@ -1354,13 +1454,14 @@ func (u *fakeUpstream) Stop() error { return nil }
// redirect to the specified backendAddr. The function
// also sets up the rules/environment for testing WebSocket
// proxy.
-func newWebSocketTestProxy(backendAddr string, insecure bool) *Proxy {
+func newWebSocketTestProxy(backendAddr string, insecure bool, timeout time.Duration) *Proxy {
return &Proxy{
Next: httpserver.EmptyNext, // prevents panic in some cases when test fails
Upstreams: []Upstream{&fakeWsUpstream{
name: backendAddr,
without: "",
insecure: insecure,
+ timeout: timeout,
}},
}
}
@@ -1368,7 +1469,7 @@ func newWebSocketTestProxy(backendAddr string, insecure bool) *Proxy {
func newPrefixedWebSocketTestProxy(backendAddr string, prefix string) *Proxy {
return &Proxy{
Next: httpserver.EmptyNext, // prevents panic in some cases when test fails
- Upstreams: []Upstream{&fakeWsUpstream{name: backendAddr, without: prefix}},
+ Upstreams: []Upstream{&fakeWsUpstream{name: backendAddr, without: prefix, timeout: 30 * time.Second}},
}
}
@@ -1376,6 +1477,7 @@ type fakeWsUpstream struct {
name string
without string
insecure bool
+ timeout time.Duration
}
func (u *fakeWsUpstream) From() string {
@@ -1386,7 +1488,7 @@ func (u *fakeWsUpstream) Select(r *http.Request) *UpstreamHost {
uri, _ := url.Parse(u.name)
host := &UpstreamHost{
Name: u.name,
- ReverseProxy: NewSingleHostReverseProxy(uri, u.without, http.DefaultMaxIdleConnsPerHost),
+ ReverseProxy: NewSingleHostReverseProxy(uri, u.without, http.DefaultMaxIdleConnsPerHost, u.GetTimeout()),
UpstreamHeaders: http.Header{
"Connection": {"{>Connection}"},
"Upgrade": {"{>Upgrade}"}},
@@ -1400,6 +1502,7 @@ func (u *fakeWsUpstream) Select(r *http.Request) *UpstreamHost {
func (u *fakeWsUpstream) AllowedPath(requestPath string) bool { return true }
func (u *fakeWsUpstream) GetTryDuration() time.Duration { return 1 * time.Second }
func (u *fakeWsUpstream) GetTryInterval() time.Duration { return 250 * time.Millisecond }
+func (u *fakeWsUpstream) GetTimeout() time.Duration { return u.timeout }
func (u *fakeWsUpstream) GetHostCount() int { return 1 }
func (u *fakeWsUpstream) Stop() error { return nil }
@@ -1445,7 +1548,7 @@ func BenchmarkProxy(b *testing.B) {
}))
defer backend.Close()
- upstream := newFakeUpstream(backend.URL, false)
+ upstream := newFakeUpstream(backend.URL, false, 30*time.Second)
upstream.host.UpstreamHeaders = http.Header{
"Hostname": {"{hostname}"},
"Host": {"{host}"},
@@ -1488,7 +1591,7 @@ func TestChunkedWebSocketReverseProxy(t *testing.T) {
defer wsNop.Close()
// Get proxy to use for the test
- p := newWebSocketTestProxy(wsNop.URL, false)
+ p := newWebSocketTestProxy(wsNop.URL, false, 30*time.Second)
// Create client request
r := httptest.NewRequest("GET", "/", nil)
diff --git a/caddyhttp/proxy/reverseproxy.go b/caddyhttp/proxy/reverseproxy.go
index d48894ff1..c528cf451 100644
--- a/caddyhttp/proxy/reverseproxy.go
+++ b/caddyhttp/proxy/reverseproxy.go
@@ -94,6 +94,10 @@ type ReverseProxy struct {
// If zero, no periodic flushing is done.
FlushInterval time.Duration
+ // dialer is used when values from the
+ // defaultDialer need to be overridden per Proxy
+ dialer *net.Dialer
+
srvResolver srvResolver
}
@@ -103,13 +107,13 @@ type ReverseProxy struct {
// What we need is just the path, so if "unix:/var/run/www.socket"
// was the proxy directive, the parsed hostName would be
// "unix:///var/run/www.socket", hence the ambiguous trimming.
-func socketDial(hostName string) func(network, addr string) (conn net.Conn, err error) {
+func socketDial(hostName string, timeout time.Duration) func(network, addr string) (conn net.Conn, err error) {
return func(network, addr string) (conn net.Conn, err error) {
- return net.Dial("unix", hostName[len("unix://"):])
+ return net.DialTimeout("unix", hostName[len("unix://"):], timeout)
}
}
-func (rp *ReverseProxy) srvDialerFunc(locator string) func(network, addr string) (conn net.Conn, err error) {
+func (rp *ReverseProxy) srvDialerFunc(locator string, timeout time.Duration) func(network, addr string) (conn net.Conn, err error) {
service := locator
if strings.HasPrefix(locator, "srv://") {
service = locator[6:]
@@ -122,7 +126,7 @@ func (rp *ReverseProxy) srvDialerFunc(locator string) func(network, addr string)
if err != nil {
return nil, err
}
- return net.Dial("tcp", fmt.Sprintf("%s:%d", addrs[0].Target, addrs[0].Port))
+ return net.DialTimeout("tcp", fmt.Sprintf("%s:%d", addrs[0].Target, addrs[0].Port), timeout)
}
}
@@ -144,7 +148,7 @@ func singleJoiningSlash(a, b string) string {
// the target request will be for /base/dir.
// Without logic: target's path is "/", incoming is "/api/messages",
// without is "/api", then the target request will be for /messages.
-func NewSingleHostReverseProxy(target *url.URL, without string, keepalive int) *ReverseProxy {
+func NewSingleHostReverseProxy(target *url.URL, without string, keepalive int, timeout time.Duration) *ReverseProxy {
targetQuery := target.RawQuery
director := func(req *http.Request) {
if target.Scheme == "unix" {
@@ -226,15 +230,21 @@ func NewSingleHostReverseProxy(target *url.URL, without string, keepalive int) *
}
}
+ dialer := *defaultDialer
+ if timeout != defaultDialer.Timeout {
+ dialer.Timeout = timeout
+ }
+
rp := &ReverseProxy{
Director: director,
FlushInterval: 250 * time.Millisecond, // flushing good for streaming & server-sent events
srvResolver: net.DefaultResolver,
+ dialer: &dialer,
}
if target.Scheme == "unix" {
rp.Transport = &http.Transport{
- Dial: socketDial(target.String()),
+ Dial: socketDial(target.String(), timeout),
}
} else if target.Scheme == "quic" {
rp.Transport = &h2quic.RoundTripper{
@@ -244,9 +254,9 @@ func NewSingleHostReverseProxy(target *url.URL, without string, keepalive int) *
},
}
} else if keepalive != http.DefaultMaxIdleConnsPerHost || strings.HasPrefix(target.Scheme, "srv") {
- dialFunc := defaultDialer.Dial
+ dialFunc := rp.dialer.Dial
if strings.HasPrefix(target.Scheme, "srv") {
- dialFunc = rp.srvDialerFunc(target.String())
+ dialFunc = rp.srvDialerFunc(target.String(), timeout)
}
transport := &http.Transport{
@@ -275,7 +285,7 @@ func (rp *ReverseProxy) UseInsecureTransport() {
if rp.Transport == nil {
transport := &http.Transport{
Proxy: http.ProxyFromEnvironment,
- Dial: defaultDialer.Dial,
+ Dial: rp.dialer.Dial,
TLSHandshakeTimeout: defaultCryptoHandshakeTimeout,
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
@@ -306,7 +316,9 @@ func (rp *ReverseProxy) ServeHTTP(rw http.ResponseWriter, outreq *http.Request,
if requestIsWebsocket(outreq) {
transport = newConnHijackerTransport(transport)
} else if transport == nil {
- transport = http.DefaultTransport
+ transport = &http.Transport{
+ Dial: rp.dialer.Dial,
+ }
}
rp.Director(outreq)
@@ -361,7 +373,7 @@ func (rp *ReverseProxy) ServeHTTP(rw http.ResponseWriter, outreq *http.Request,
}
bufferPool.Put(hj.Replay)
} else {
- backendConn, err = net.Dial("tcp", outreq.URL.Host)
+ backendConn, err = net.DialTimeout("tcp", outreq.URL.Host, rp.dialer.Timeout)
if err != nil {
return err
}
diff --git a/caddyhttp/proxy/reverseproxy_test.go b/caddyhttp/proxy/reverseproxy_test.go
index 2d1d80df4..8b01054e5 100644
--- a/caddyhttp/proxy/reverseproxy_test.go
+++ b/caddyhttp/proxy/reverseproxy_test.go
@@ -21,6 +21,7 @@ import (
"net/url"
"strconv"
"testing"
+ "time"
)
const (
@@ -66,7 +67,7 @@ func TestSingleSRVHostReverseProxy(t *testing.T) {
}
port := uint16(pp)
- rp := NewSingleHostReverseProxy(target, "", http.DefaultMaxIdleConnsPerHost)
+ rp := NewSingleHostReverseProxy(target, "", http.DefaultMaxIdleConnsPerHost, 30*time.Second)
rp.srvResolver = testResolver{
result: []*net.SRV{
{Target: upstream.Hostname(), Port: port, Priority: 1, Weight: 1},
diff --git a/caddyhttp/proxy/upstream.go b/caddyhttp/proxy/upstream.go
index ae15a6dcb..8e5395c6b 100644
--- a/caddyhttp/proxy/upstream.go
+++ b/caddyhttp/proxy/upstream.go
@@ -49,6 +49,7 @@ type staticUpstream struct {
Hosts HostPool
Policy Policy
KeepAlive int
+ Timeout time.Duration
FailTimeout time.Duration
TryDuration time.Duration
TryInterval time.Duration
@@ -92,6 +93,7 @@ func NewStaticUpstreams(c caddyfile.Dispenser, host string) ([]Upstream, error)
TryInterval: 250 * time.Millisecond,
MaxConns: 0,
KeepAlive: http.DefaultMaxIdleConnsPerHost,
+ Timeout: 30 * time.Second,
resolver: net.DefaultResolver,
}
@@ -225,7 +227,7 @@ func (u *staticUpstream) NewHost(host string) (*UpstreamHost, error) {
return nil, err
}
- uh.ReverseProxy = NewSingleHostReverseProxy(baseURL, uh.WithoutPathPrefix, u.KeepAlive)
+ uh.ReverseProxy = NewSingleHostReverseProxy(baseURL, uh.WithoutPathPrefix, u.KeepAlive, u.Timeout)
if u.insecureSkipVerify {
uh.ReverseProxy.UseInsecureTransport()
}
@@ -431,9 +433,10 @@ func parseBlock(c *caddyfile.Dispenser, u *staticUpstream, hasSrv bool) error {
}
u.downstreamHeaders.Add(header, value)
case "transparent":
+ // Note: X-Forwarded-For header is always being appended for proxy connections
+ // See implementation of createUpstreamRequest in proxy.go
u.upstreamHeaders.Add("Host", "{host}")
u.upstreamHeaders.Add("X-Real-IP", "{remote}")
- u.upstreamHeaders.Add("X-Forwarded-For", "{remote}")
u.upstreamHeaders.Add("X-Forwarded-Proto", "{scheme}")
case "websocket":
u.upstreamHeaders.Add("Connection", "{>Connection}")
@@ -463,6 +466,15 @@ func parseBlock(c *caddyfile.Dispenser, u *staticUpstream, hasSrv bool) error {
return c.ArgErr()
}
u.KeepAlive = n
+ case "timeout":
+ if !c.NextArg() {
+ return c.ArgErr()
+ }
+ dur, err := time.ParseDuration(c.Val())
+ if err != nil {
+ return c.Errf("unable to parse timeout duration '%s'", c.Val())
+ }
+ u.Timeout = dur
default:
return c.Errf("unknown property '%s'", c.Val())
}
@@ -618,6 +630,11 @@ func (u *staticUpstream) GetTryInterval() time.Duration {
return u.TryInterval
}
+// GetTimeout returns u.Timeout.
+func (u *staticUpstream) GetTimeout() time.Duration {
+ return u.Timeout
+}
+
func (u *staticUpstream) GetHostCount() int {
return len(u.Hosts)
}
diff --git a/caddyhttp/proxy/upstream_test.go b/caddyhttp/proxy/upstream_test.go
index 23fd4831b..18c652303 100644
--- a/caddyhttp/proxy/upstream_test.go
+++ b/caddyhttp/proxy/upstream_test.go
@@ -282,7 +282,8 @@ func TestStop(t *testing.T) {
}
}
-func TestParseBlock(t *testing.T) {
+func TestParseBlockTransparent(t *testing.T) {
+ // tests for transparent proxy presets
r, _ := http.NewRequest("GET", "/", nil)
tests := []struct {
config string
@@ -316,6 +317,10 @@ func TestParseBlock(t *testing.T) {
if _, ok := headers["X-Forwarded-Proto"]; !ok {
t.Errorf("Test %d: Could not find the X-Forwarded-Proto header", i+1)
}
+
+ if _, ok := headers["X-Forwarded-For"]; ok {
+ t.Errorf("Test %d: Found unexpected X-Forwarded-For header", i+1)
+ }
}
}
}
diff --git a/caddyhttp/rewrite/rewrite.go b/caddyhttp/rewrite/rewrite.go
index 1c8d848a6..f7f56cd39 100644
--- a/caddyhttp/rewrite/rewrite.go
+++ b/caddyhttp/rewrite/rewrite.go
@@ -63,22 +63,38 @@ type Rule interface {
// SimpleRule is a simple rewrite rule.
type SimpleRule struct {
- From, To string
+ Regexp *regexp.Regexp
+ To string
+ Negate bool
}
// NewSimpleRule creates a new Simple Rule
-func NewSimpleRule(from, to string) SimpleRule {
- return SimpleRule{from, to}
+func NewSimpleRule(from, to string, negate bool) (*SimpleRule, error) {
+ r, err := regexp.Compile(from)
+ if err != nil {
+ return nil, err
+ }
+ return &SimpleRule{
+ Regexp: r,
+ To: to,
+ Negate: negate,
+ }, nil
}
// BasePath satisfies httpserver.Config
-func (s SimpleRule) BasePath() string { return s.From }
+func (s SimpleRule) BasePath() string { return "/" }
// Match satisfies httpserver.Config
-func (s SimpleRule) Match(r *http.Request) bool { return s.From == r.URL.Path }
+func (s *SimpleRule) Match(r *http.Request) bool {
+ matches := regexpMatches(s.Regexp, "/", r.URL.Path)
+ if s.Negate {
+ return len(matches) == 0
+ }
+ return len(matches) > 0
+}
// Rewrite rewrites the internal location of the current request.
-func (s SimpleRule) Rewrite(fs http.FileSystem, r *http.Request) Result {
+func (s *SimpleRule) Rewrite(fs http.FileSystem, r *http.Request) Result {
// attempt rewrite
return To(fs, r, s.To, newReplacer(r))
@@ -165,7 +181,7 @@ func (r ComplexRule) Match(req *http.Request) bool {
return true
}
// otherwise validate regex
- return r.regexpMatches(req.URL.Path) != nil
+ return regexpMatches(r.Regexp, r.Base, req.URL.Path) != nil
}
// Rewrite rewrites the internal location of the current request.
@@ -174,7 +190,7 @@ func (r ComplexRule) Rewrite(fs http.FileSystem, req *http.Request) (re Result)
// validate regexp if present
if r.Regexp != nil {
- matches := r.regexpMatches(req.URL.Path)
+ matches := regexpMatches(r.Regexp, r.Base, req.URL.Path)
switch len(matches) {
case 0:
// no match
@@ -230,14 +246,14 @@ func (r ComplexRule) matchExt(rPath string) bool {
return !mustUse
}
-func (r ComplexRule) regexpMatches(rPath string) []string {
- if r.Regexp != nil {
+func regexpMatches(regexp *regexp.Regexp, base, rPath string) []string {
+ if regexp != nil {
// include trailing slash in regexp if present
- start := len(r.Base)
- if strings.HasSuffix(r.Base, "/") {
+ start := len(base)
+ if strings.HasSuffix(base, "/") {
start--
}
- return r.Regexp.FindStringSubmatch(rPath[start:])
+ return regexp.FindStringSubmatch(rPath[start:])
}
return nil
}
diff --git a/caddyhttp/rewrite/rewrite_test.go b/caddyhttp/rewrite/rewrite_test.go
index d2c4d9859..b0700ec0b 100644
--- a/caddyhttp/rewrite/rewrite_test.go
+++ b/caddyhttp/rewrite/rewrite_test.go
@@ -29,9 +29,9 @@ func TestRewrite(t *testing.T) {
rw := Rewrite{
Next: httpserver.HandlerFunc(urlPrinter),
Rules: []httpserver.HandlerConfig{
- NewSimpleRule("/from", "/to"),
- NewSimpleRule("/a", "/b"),
- NewSimpleRule("/b", "/b{uri}"),
+ newSimpleRule(t, "^/from$", "/to"),
+ newSimpleRule(t, "^/a$", "/b"),
+ newSimpleRule(t, "^/b$", "/b{uri}"),
},
FileSys: http.Dir("."),
}
@@ -131,6 +131,45 @@ func TestRewrite(t *testing.T) {
}
}
+// TestWordpress is a test for wordpress usecase.
+func TestWordpress(t *testing.T) {
+ rw := Rewrite{
+ Next: httpserver.HandlerFunc(urlPrinter),
+ Rules: []httpserver.HandlerConfig{
+ // both rules are same, thanks to Go regexp (confusion).
+ newSimpleRule(t, "^/wp-admin", "{path} {path}/ /index.php?{query}", true),
+ newSimpleRule(t, "^\\/wp-admin", "{path} {path}/ /index.php?{query}", true),
+ },
+ FileSys: http.Dir("."),
+ }
+ tests := []struct {
+ from string
+ expectedTo string
+ }{
+ {"/wp-admin", "/wp-admin"},
+ {"/wp-admin/login.php", "/wp-admin/login.php"},
+ {"/not-wp-admin/login.php?not=admin", "/index.php?not=admin"},
+ {"/loophole", "/index.php"},
+ {"/user?name=john", "/index.php?name=john"},
+ }
+
+ for i, test := range tests {
+ req, err := http.NewRequest("GET", test.from, nil)
+ if err != nil {
+ t.Fatalf("Test %d: Could not create HTTP request: %v", i, err)
+ }
+ ctx := context.WithValue(req.Context(), httpserver.OriginalURLCtxKey, *req.URL)
+ req = req.WithContext(ctx)
+
+ rec := httptest.NewRecorder()
+ rw.ServeHTTP(rec, req)
+
+ if got, want := rec.Body.String(), test.expectedTo; got != want {
+ t.Errorf("Test %d: Expected URL to be '%s' but was '%s'", i, want, got)
+ }
+ }
+}
+
func urlPrinter(w http.ResponseWriter, r *http.Request) (int, error) {
fmt.Fprint(w, r.URL.String())
return 0, nil
diff --git a/caddyhttp/rewrite/setup.go b/caddyhttp/rewrite/setup.go
index abaf61271..f73d76a70 100644
--- a/caddyhttp/rewrite/setup.go
+++ b/caddyhttp/rewrite/setup.go
@@ -58,6 +58,7 @@ func rewriteParse(c *caddy.Controller) ([]httpserver.HandlerConfig, error) {
var base = "/"
var pattern, to string
var ext []string
+ var negate bool
args := c.RemainingArgs()
@@ -111,7 +112,14 @@ func rewriteParse(c *caddy.Controller) ([]httpserver.HandlerConfig, error) {
// the only unhandled case is 2 and above
default:
- rule = NewSimpleRule(args[0], strings.Join(args[1:], " "))
+ if args[0] == "not" {
+ negate = true
+ args = args[1:]
+ }
+ rule, err = NewSimpleRule(args[0], strings.Join(args[1:], " "), negate)
+ if err != nil {
+ return nil, err
+ }
rules = append(rules, rule)
}
diff --git a/caddyhttp/rewrite/setup_test.go b/caddyhttp/rewrite/setup_test.go
index 68256e969..e192242d0 100644
--- a/caddyhttp/rewrite/setup_test.go
+++ b/caddyhttp/rewrite/setup_test.go
@@ -50,6 +50,19 @@ func TestSetup(t *testing.T) {
}
}
+// newSimpleRule is convenience test function for SimpleRule.
+func newSimpleRule(t *testing.T, from, to string, negate ...bool) Rule {
+ var n bool
+ if len(negate) > 0 {
+ n = negate[0]
+ }
+ rule, err := NewSimpleRule(from, to, n)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return rule
+}
+
func TestRewriteParse(t *testing.T) {
simpleTests := []struct {
input string
@@ -57,17 +70,20 @@ func TestRewriteParse(t *testing.T) {
expected []Rule
}{
{`rewrite /from /to`, false, []Rule{
- SimpleRule{From: "/from", To: "/to"},
+ newSimpleRule(t, "/from", "/to"),
}},
{`rewrite /from /to
rewrite a b`, false, []Rule{
- SimpleRule{From: "/from", To: "/to"},
- SimpleRule{From: "a", To: "b"},
+ newSimpleRule(t, "/from", "/to"),
+ newSimpleRule(t, "a", "b"),
}},
{`rewrite a`, true, []Rule{}},
{`rewrite`, true, []Rule{}},
{`rewrite a b c`, false, []Rule{
- SimpleRule{From: "a", To: "b c"},
+ newSimpleRule(t, "a", "b c"),
+ }},
+ {`rewrite not a b c`, false, []Rule{
+ newSimpleRule(t, "a", "b c", true),
}},
}
@@ -88,17 +104,22 @@ func TestRewriteParse(t *testing.T) {
}
for j, e := range test.expected {
- actualRule := actual[j].(SimpleRule)
- expectedRule := e.(SimpleRule)
+ actualRule := actual[j].(*SimpleRule)
+ expectedRule := e.(*SimpleRule)
- if actualRule.From != expectedRule.From {
+ if actualRule.Regexp.String() != expectedRule.Regexp.String() {
t.Errorf("Test %d, rule %d: Expected From=%s, got %s",
- i, j, expectedRule.From, actualRule.From)
+ i, j, expectedRule.Regexp.String(), actualRule.Regexp.String())
}
if actualRule.To != expectedRule.To {
t.Errorf("Test %d, rule %d: Expected To=%s, got %s",
- i, j, expectedRule.To, actualRule.To)
+ i, j, expectedRule.Regexp.String(), actualRule.Regexp.String())
+ }
+
+ if actualRule.Negate != expectedRule.Negate {
+ t.Errorf("Test %d, rule %d: Expected Negate=%v, got %v",
+ i, j, expectedRule.Negate, actualRule.Negate)
}
}
}
diff --git a/caddytls/certificates.go b/caddytls/certificates.go
index b021134bb..7df4e11d6 100644
--- a/caddytls/certificates.go
+++ b/caddytls/certificates.go
@@ -265,21 +265,21 @@ func fillCertFromLeaf(cert *Certificate, tlsCert tls.Certificate) error {
return err
}
- if leaf.Subject.CommonName != "" {
+ if leaf.Subject.CommonName != "" { // TODO: CommonName is deprecated
cert.Names = []string{strings.ToLower(leaf.Subject.CommonName)}
}
for _, name := range leaf.DNSNames {
- if name != leaf.Subject.CommonName {
+ if name != leaf.Subject.CommonName { // TODO: CommonName is deprecated
cert.Names = append(cert.Names, strings.ToLower(name))
}
}
for _, ip := range leaf.IPAddresses {
- if ipStr := ip.String(); ipStr != leaf.Subject.CommonName {
+ if ipStr := ip.String(); ipStr != leaf.Subject.CommonName { // TODO: CommonName is deprecated
cert.Names = append(cert.Names, strings.ToLower(ipStr))
}
}
for _, email := range leaf.EmailAddresses {
- if email != leaf.Subject.CommonName {
+ if email != leaf.Subject.CommonName { // TODO: CommonName is deprecated
cert.Names = append(cert.Names, strings.ToLower(email))
}
}
diff --git a/caddytls/certificates_test.go b/caddytls/certificates_test.go
index 817d16496..5f8b17e18 100644
--- a/caddytls/certificates_test.go
+++ b/caddytls/certificates_test.go
@@ -43,10 +43,11 @@ func TestUnexportedGetCertificate(t *testing.T) {
t.Errorf("Didn't get wildcard cert for 'sub.example.com' or got the wrong one: %v, matched=%v, defaulted=%v", cert, matched, defaulted)
}
- // When no certificate matches and SNI is provided, return no certificate (should be TLS alert)
- if cert, matched, defaulted := cfg.getCertificate("nomatch"); matched || defaulted {
- t.Errorf("Expected matched=false, defaulted=false; but got matched=%v, defaulted=%v (cert: %v)", matched, defaulted, cert)
- }
+ // TODO: Re-implement this behavior when I'm not in the middle of upgrading for ACMEv2 support. :) (it was reverted in #2037)
+ // // When no certificate matches and SNI is provided, return no certificate (should be TLS alert)
+ // if cert, matched, defaulted := cfg.getCertificate("nomatch"); matched || defaulted {
+ // t.Errorf("Expected matched=false, defaulted=false; but got matched=%v, defaulted=%v (cert: %v)", matched, defaulted, cert)
+ // }
// When no certificate matches and SNI is NOT provided, a random is returned
if cert, matched, defaulted := cfg.getCertificate(""); matched || !defaulted {
diff --git a/caddytls/client.go b/caddytls/client.go
index 08b0af38d..2b27f515d 100644
--- a/caddytls/client.go
+++ b/caddytls/client.go
@@ -27,7 +27,7 @@ import (
"github.com/mholt/caddy"
"github.com/mholt/caddy/telemetry"
- "github.com/xenolf/lego/acme"
+ "github.com/xenolf/lego/acmev2"
)
// acmeMu ensures that only one ACME challenge occurs at a time.
@@ -90,27 +90,22 @@ var newACMEClient = func(config *Config, allowPrompts bool) (*ACMEClient, error)
// If not registered, the user must register an account with the CA
// and agree to terms
if leUser.Registration == nil {
- reg, err := client.Register()
+ if allowPrompts { // can't prompt a user who isn't there
+ termsURL := client.GetToSURL()
+ if !Agreed && termsURL != "" {
+ Agreed = askUserAgreement(client.GetToSURL())
+ }
+ if !Agreed && termsURL != "" {
+ return nil, errors.New("user must agree to CA terms (use -agree flag)")
+ }
+ }
+
+ reg, err := client.Register(Agreed)
if err != nil {
return nil, errors.New("registration error: " + err.Error())
}
leUser.Registration = reg
- if allowPrompts { // can't prompt a user who isn't there
- if !Agreed && reg.TosURL == "" {
- Agreed = promptUserAgreement(saURL, false) // TODO - latest URL
- }
- if !Agreed && reg.TosURL == "" {
- return nil, errors.New("user must agree to terms")
- }
- }
-
- err = client.AgreeToTOS()
- if err != nil {
- saveUser(storage, leUser) // Might as well try, right?
- return nil, errors.New("error agreeing to terms: " + err.Error())
- }
-
// save user to the file system
err = saveUser(storage, leUser)
if err != nil {
@@ -137,38 +132,57 @@ var newACMEClient = func(config *Config, allowPrompts bool) (*ACMEClient, error)
useHTTPPort = DefaultHTTPAlternatePort
}
+ // TODO: tls-sni challenge was removed in January 2018, but a variant of it might return
// See which port TLS-SNI challenges will be accomplished on
- useTLSSNIPort := TLSSNIChallengePort
- if config.AltTLSSNIPort != "" {
- useTLSSNIPort = config.AltTLSSNIPort
- }
-
- // Always respect user's bind preferences by using config.ListenHost.
- // NOTE(Sep'16): At time of writing, SetHTTPAddress() and SetTLSAddress()
- // must be called before SetChallengeProvider(), since they reset the
- // challenge provider back to the default one!
- err := c.acmeClient.SetHTTPAddress(net.JoinHostPort(config.ListenHost, useHTTPPort))
- if err != nil {
- return nil, err
- }
- err = c.acmeClient.SetTLSAddress(net.JoinHostPort(config.ListenHost, useTLSSNIPort))
- if err != nil {
- return nil, err
+ // useTLSSNIPort := TLSSNIChallengePort
+ // if config.AltTLSSNIPort != "" {
+ // useTLSSNIPort = config.AltTLSSNIPort
+ // }
+ // err := c.acmeClient.SetTLSAddress(net.JoinHostPort(config.ListenHost, useTLSSNIPort))
+ // if err != nil {
+ // return nil, err
+ // }
+
+ // if using file storage, we can distribute the HTTP challenge across
+ // all instances sharing the acme folder; either way, we must still set
+ // the address for the default HTTP provider server
+ var useDistributedHTTPSolver bool
+ if storage, err := c.config.StorageFor(c.config.CAUrl); err == nil {
+ if _, ok := storage.(*FileStorage); ok {
+ useDistributedHTTPSolver = true
+ }
+ }
+ if useDistributedHTTPSolver {
+ c.acmeClient.SetChallengeProvider(acme.HTTP01, distributedHTTPSolver{
+ // being careful to respect user's listener bind preferences
+ httpProviderServer: acme.NewHTTPProviderServer(config.ListenHost, useHTTPPort),
+ })
+ } else {
+ // Always respect user's bind preferences by using config.ListenHost.
+ // NOTE(Sep'16): At time of writing, SetHTTPAddress() and SetTLSAddress()
+ // must be called before SetChallengeProvider() (see above), since they reset
+ // the challenge provider back to the default one! (still true in March 2018)
+ err := c.acmeClient.SetHTTPAddress(net.JoinHostPort(config.ListenHost, useHTTPPort))
+ if err != nil {
+ return nil, err
+ }
}
+ // TODO: tls-sni challenge was removed in January 2018, but a variant of it might return
// See if TLS challenge needs to be handled by our own facilities
- if caddy.HasListenerWithAddress(net.JoinHostPort(config.ListenHost, useTLSSNIPort)) {
- c.acmeClient.SetChallengeProvider(acme.TLSSNI01, tlsSNISolver{certCache: config.certCache})
- }
+ // if caddy.HasListenerWithAddress(net.JoinHostPort(config.ListenHost, useTLSSNIPort)) {
+ // c.acmeClient.SetChallengeProvider(acme.TLSSNI01, tlsSNISolver{certCache: config.certCache})
+ // }
// Disable any challenges that should not be used
var disabledChallenges []acme.Challenge
if DisableHTTPChallenge {
disabledChallenges = append(disabledChallenges, acme.HTTP01)
}
- if DisableTLSSNIChallenge {
- disabledChallenges = append(disabledChallenges, acme.TLSSNI01)
- }
+ // TODO: tls-sni challenge was removed in January 2018, but a variant of it might return
+ // if DisableTLSSNIChallenge {
+ // disabledChallenges = append(disabledChallenges, acme.TLSSNI01)
+ // }
if len(disabledChallenges) > 0 {
c.acmeClient.ExcludeChallenges(disabledChallenges)
}
@@ -189,7 +203,9 @@ var newACMEClient = func(config *Config, allowPrompts bool) (*ACMEClient, error)
}
// Use the DNS challenge exclusively
- c.acmeClient.ExcludeChallenges([]acme.Challenge{acme.HTTP01, acme.TLSSNI01})
+ // TODO: tls-sni challenge was removed in January 2018, but a variant of it might return
+ // c.acmeClient.ExcludeChallenges([]acme.Challenge{acme.HTTP01, acme.TLSSNI01})
+ c.acmeClient.ExcludeChallenges([]acme.Challenge{acme.HTTP01})
c.acmeClient.SetChallengeProvider(acme.DNS01, prov)
}
@@ -222,41 +238,31 @@ func (c *ACMEClient) Obtain(name string) error {
}
}()
-Attempts:
for attempts := 0; attempts < 2; attempts++ {
namesObtaining.Add([]string{name})
acmeMu.Lock()
- certificate, failures := c.acmeClient.ObtainCertificate([]string{name}, true, nil, c.config.MustStaple)
+ certificate, err := c.acmeClient.ObtainCertificate([]string{name}, true, nil, c.config.MustStaple)
acmeMu.Unlock()
namesObtaining.Remove([]string{name})
- if len(failures) > 0 {
- // Error - try to fix it or report it to the user and abort
- var errMsg string // we'll combine all the failures into a single error message
- var promptedForAgreement bool // only prompt user for agreement at most once
-
- for errDomain, obtainErr := range failures {
- if obtainErr == nil {
- continue
- }
- if tosErr, ok := obtainErr.(acme.TOSError); ok {
- // Terms of Service agreement error; we can probably deal with this
- if !Agreed && !promptedForAgreement && c.AllowPrompts {
- Agreed = promptUserAgreement(tosErr.Detail, true) // TODO: Use latest URL
- promptedForAgreement = true
- }
- if Agreed || !c.AllowPrompts {
- err := c.acmeClient.AgreeToTOS()
- if err != nil {
- return errors.New("error agreeing to updated terms: " + err.Error())
- }
- continue Attempts
+ if err != nil {
+ // for a certain kind of error, we can enumerate the error per-domain
+ if failures, ok := err.(acme.ObtainError); ok && len(failures) > 0 {
+ var errMsg string // combine all the failures into a single error message
+ for errDomain, obtainErr := range failures {
+ if obtainErr == nil {
+ continue
}
+ errMsg += fmt.Sprintf("[%s] failed to get certificate: %v\n", errDomain, obtainErr)
}
-
- // If user did not agree or it was any other kind of error, just append to the list of errors
- errMsg += "[" + errDomain + "] failed to get certificate: " + obtainErr.Error() + "\n"
+ return errors.New(errMsg)
}
- return errors.New(errMsg)
+
+ return fmt.Errorf("[%s] failed to obtain certificate: %v", name, err)
+ }
+
+ // double-check that we actually got a certificate, in case there's a bug upstream (see issue #2121)
+ if certificate.Domain == "" || certificate.Certificate == nil {
+ return errors.New("returned certificate was empty; probably an unchecked error obtaining it")
}
// Success - immediately save the certificate resource
@@ -315,23 +321,20 @@ func (c *ACMEClient) Renew(name string) error {
acmeMu.Unlock()
namesObtaining.Remove([]string{name})
if err == nil {
- success = true
- break
- }
-
- // If the legal terms were updated and need to be
- // agreed to again, we can handle that.
- if _, ok := err.(acme.TOSError); ok {
- err := c.acmeClient.AgreeToTOS()
- if err != nil {
- return err
+ // double-check that we actually got a certificate; check a couple fields
+ // TODO: This is a temporary workaround for what I think is a bug in the acmev2 package (March 2018)
+ // but it might not hurt to keep this extra check in place
+ if newCertMeta.Domain == "" || newCertMeta.Certificate == nil {
+ err = errors.New("returned certificate was empty; probably an unchecked error renewing it")
+ } else {
+ success = true
+ break
}
- continue
}
- // For any other kind of error, wait 10s and try again.
+ // wait a little bit and try again
wait := 10 * time.Second
- log.Printf("[ERROR] Renewing: %v; trying again in %s", err, wait)
+ log.Printf("[ERROR] Renewing [%v]: %v; trying again in %s", name, err, wait)
time.Sleep(wait)
}
diff --git a/caddytls/config.go b/caddytls/config.go
index 34f71f761..4a9f5451a 100644
--- a/caddytls/config.go
+++ b/caddytls/config.go
@@ -25,7 +25,7 @@ import (
"github.com/klauspost/cpuid"
"github.com/mholt/caddy"
- "github.com/xenolf/lego/acme"
+ "github.com/xenolf/lego/acmev2"
)
// Config describes how TLS should be configured and used.
@@ -190,10 +190,15 @@ func NewConfig(inst *caddy.Instance) *Config {
// it does not load them into memory. If allowPrompts is true,
// the user may be shown a prompt.
func (c *Config) ObtainCert(name string, allowPrompts bool) error {
- if !c.Managed || !HostQualifies(name) {
+ skip, err := c.preObtainOrRenewChecks(name, allowPrompts)
+ if err != nil {
+ return err
+ }
+ if skip {
return nil
}
+ // we expect this to be a new (non-existent) site
storage, err := c.StorageFor(c.CAUrl)
if err != nil {
return err
@@ -205,9 +210,6 @@ func (c *Config) ObtainCert(name string, allowPrompts bool) error {
if siteExists {
return nil
}
- if c.ACMEEmail == "" {
- c.ACMEEmail = getEmail(storage, allowPrompts)
- }
client, err := newACMEClient(c, allowPrompts)
if err != nil {
@@ -219,6 +221,14 @@ func (c *Config) ObtainCert(name string, allowPrompts bool) error {
// RenewCert renews the certificate for name using c. It stows the
// renewed certificate and its assets in storage if successful.
func (c *Config) RenewCert(name string, allowPrompts bool) error {
+ skip, err := c.preObtainOrRenewChecks(name, allowPrompts)
+ if err != nil {
+ return err
+ }
+ if skip {
+ return nil
+ }
+
client, err := newACMEClient(c, allowPrompts)
if err != nil {
return err
@@ -226,6 +236,33 @@ func (c *Config) RenewCert(name string, allowPrompts bool) error {
return client.Renew(name)
}
+// preObtainOrRenewChecks perform a few simple checks before
+// obtaining or renewing a certificate with ACME, and returns
+// whether this name should be skipped (like if it's not
+// managed TLS) as well as any error. It ensures that the
+// config is Managed, that the name qualifies for a certificate,
+// and that an email address is available.
+func (c *Config) preObtainOrRenewChecks(name string, allowPrompts bool) (bool, error) {
+ if !c.Managed || !HostQualifies(name) {
+ return true, nil
+ }
+
+ // wildcard certificates require DNS challenge (as of March 2018)
+ if strings.Contains(name, "*") && c.DNSProvider == "" {
+ return false, fmt.Errorf("wildcard domain name (%s) requires DNS challenge; use dns subdirective to configure it", name)
+ }
+
+ if c.ACMEEmail == "" {
+ var err error
+ c.ACMEEmail, err = getEmail(c, allowPrompts)
+ if err != nil {
+ return false, err
+ }
+ }
+
+ return false, nil
+}
+
// StorageFor obtains a TLS Storage instance for the given CA URL which should
// be unique for every different ACME CA. If a StorageCreator is set on this
// Config, it will be used. Otherwise the default file storage implementation
@@ -476,6 +513,14 @@ func assertConfigsCompatible(cfg1, cfg2 *Config) error {
if c1.ClientAuth != c2.ClientAuth {
return fmt.Errorf("client authentication policy mismatch")
}
+ if c1.ClientAuth != tls.NoClientCert && c2.ClientAuth != tls.NoClientCert && c1.ClientCAs != c2.ClientCAs {
+ // Two hosts defined on the same listener are not compatible if they
+ // have ClientAuth enabled, because there's no guarantee beyond the
+ // hostname which config will be used (because SNI only has server name).
+ // To prevent clients from bypassing authentication, require that
+ // ClientAuth be configured in an unambiguous manner.
+ return fmt.Errorf("multiple hosts requiring client authentication ambiguously configured")
+ }
return nil
}
@@ -511,7 +556,7 @@ func SetDefaultTLSParams(config *Config) {
// Set default protocol min and max versions - must balance compatibility and security
if config.ProtocolMinVersion == 0 {
- config.ProtocolMinVersion = tls.VersionTLS11
+ config.ProtocolMinVersion = tls.VersionTLS12
}
if config.ProtocolMaxVersion == 0 {
config.ProtocolMaxVersion = tls.VersionTLS12
@@ -532,7 +577,8 @@ var supportedKeyTypes = map[string]acme.KeyType{
// Map of supported protocols.
// HTTP/2 only supports TLS 1.2 and higher.
-var supportedProtocols = map[string]uint16{
+// If updating this map, also update tlsProtocolStringToMap in caddyhttp/fastcgi/fastcgi.go
+var SupportedProtocols = map[string]uint16{
"tls1.0": tls.VersionTLS10,
"tls1.1": tls.VersionTLS11,
"tls1.2": tls.VersionTLS12,
@@ -548,7 +594,7 @@ var supportedProtocols = map[string]uint16{
// it is always added (even though it is not technically a cipher suite).
//
// This map, like any map, is NOT ORDERED. Do not range over this map.
-var supportedCiphersMap = map[string]uint16{
+var SupportedCiphersMap = map[string]uint16{
"ECDHE-ECDSA-AES256-GCM-SHA384": tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
"ECDHE-RSA-AES256-GCM-SHA384": tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
"ECDHE-ECDSA-AES128-GCM-SHA256": tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
diff --git a/caddytls/crypto.go b/caddytls/crypto.go
index b2107f152..51cab7f4d 100644
--- a/caddytls/crypto.go
+++ b/caddytls/crypto.go
@@ -35,13 +35,14 @@ import (
"net"
"os"
"path/filepath"
+ "strings"
"sync"
"time"
"golang.org/x/crypto/ocsp"
"github.com/mholt/caddy"
- "github.com/xenolf/lego/acme"
+ "github.com/xenolf/lego/acmev2"
)
// loadPrivateKey loads a PEM-encoded ECC/RSA private key from an array of bytes.
@@ -106,7 +107,8 @@ func stapleOCSP(cert *Certificate, pemBundle []byte) error {
// TODO: Use Storage interface instead of disk directly
var ocspFileNamePrefix string
if len(cert.Names) > 0 {
- ocspFileNamePrefix = cert.Names[0] + "-"
+ firstName := strings.Replace(cert.Names[0], "*", "wildcard_", -1)
+ ocspFileNamePrefix = firstName + "-"
}
ocspFileName := ocspFileNamePrefix + fastHash(pemBundle)
ocspCachePath := filepath.Join(ocspFolder, ocspFileName)
@@ -216,10 +218,13 @@ func makeSelfSignedCert(config *Config) error {
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
+ var names []string
if ip := net.ParseIP(config.Hostname); ip != nil {
+ names = append(names, strings.ToLower(ip.String()))
cert.IPAddresses = append(cert.IPAddresses, ip)
} else {
- cert.DNSNames = append(cert.DNSNames, config.Hostname)
+ names = append(names, strings.ToLower(config.Hostname))
+ cert.DNSNames = append(cert.DNSNames, strings.ToLower(config.Hostname))
}
publicKey := func(privKey interface{}) interface{} {
@@ -245,7 +250,7 @@ func makeSelfSignedCert(config *Config) error {
PrivateKey: privKey,
Leaf: cert,
},
- Names: cert.DNSNames,
+ Names: names,
NotAfter: cert.NotAfter,
Hash: hashCertificateChain(chain),
})
diff --git a/caddytls/filestorage.go b/caddytls/filestorage.go
index 9dd2d8494..a43c62b89 100644
--- a/caddytls/filestorage.go
+++ b/caddytls/filestorage.go
@@ -30,14 +30,14 @@ func init() {
RegisterStorageProvider("file", NewFileStorage)
}
-// storageBasePath is the root path in which all TLS/ACME assets are
-// stored. Do not change this value during the lifetime of the program.
-var storageBasePath = filepath.Join(caddy.AssetsPath(), "acme")
-
// NewFileStorage is a StorageConstructor function that creates a new
// Storage instance backed by the local disk. The resulting Storage
// instance is guaranteed to be non-nil if there is no error.
func NewFileStorage(caURL *url.URL) (Storage, error) {
+ // storageBasePath is the root path in which all TLS/ACME assets are
+ // stored. Do not change this value during the lifetime of the program.
+ storageBasePath := filepath.Join(caddy.AssetsPath(), "acme")
+
storage := &FileStorage{Path: filepath.Join(storageBasePath, caURL.Host)}
storage.Locker = &fileStorageLock{caURL: caURL.Host, storage: storage}
return storage, nil
@@ -58,25 +58,25 @@ func (s *FileStorage) sites() string {
// site returns the path to the folder containing assets for domain.
func (s *FileStorage) site(domain string) string {
- domain = strings.ToLower(domain)
+ domain = fileSafe(domain)
return filepath.Join(s.sites(), domain)
}
// siteCertFile returns the path to the certificate file for domain.
func (s *FileStorage) siteCertFile(domain string) string {
- domain = strings.ToLower(domain)
+ domain = fileSafe(domain)
return filepath.Join(s.site(domain), domain+".crt")
}
// siteKeyFile returns the path to domain's private key file.
func (s *FileStorage) siteKeyFile(domain string) string {
- domain = strings.ToLower(domain)
+ domain = fileSafe(domain)
return filepath.Join(s.site(domain), domain+".key")
}
// siteMetaFile returns the path to the domain's asset metadata file.
func (s *FileStorage) siteMetaFile(domain string) string {
- domain = strings.ToLower(domain)
+ domain = fileSafe(domain)
return filepath.Join(s.site(domain), domain+".json")
}
@@ -90,7 +90,7 @@ func (s *FileStorage) user(email string) string {
if email == "" {
email = emptyEmail
}
- email = strings.ToLower(email)
+ email = fileSafe(email)
return filepath.Join(s.users(), email)
}
@@ -117,6 +117,7 @@ func (s *FileStorage) userRegFile(email string) string {
if fileName == "" {
fileName = "registration"
}
+ fileName = fileSafe(fileName)
return filepath.Join(s.user(email), fileName+".json")
}
@@ -131,6 +132,7 @@ func (s *FileStorage) userKeyFile(email string) string {
if fileName == "" {
fileName = "private"
}
+ fileName = fileSafe(fileName)
return filepath.Join(s.user(email), fileName+".key")
}
@@ -274,3 +276,29 @@ func (s *FileStorage) MostRecentUserEmail() string {
}
return ""
}
+
+// fileSafe standardizes and sanitizes str for use in a file path.
+func fileSafe(str string) string {
+ str = strings.ToLower(str)
+ str = strings.TrimSpace(str)
+ repl := strings.NewReplacer("..", "",
+ "/", "",
+ "\\", "",
+ // TODO: Consider also replacing "@" with "_at_" (but migrate existing accounts...)
+ "+", "_plus_",
+ "%", "",
+ "$", "",
+ "`", "",
+ "~", "",
+ ":", "",
+ ";", "",
+ "=", "",
+ "!", "",
+ "#", "",
+ "&", "",
+ "|", "",
+ "\"", "",
+ "'", "",
+ "*", "wildcard_")
+ return repl.Replace(str)
+}
diff --git a/caddytls/filestorage_test.go b/caddytls/filestorage_test.go
index 1831f7b93..e28dfc403 100644
--- a/caddytls/filestorage_test.go
+++ b/caddytls/filestorage_test.go
@@ -14,7 +14,71 @@
package caddytls
+import (
+ "path/filepath"
+ "testing"
+)
+
// *********************************** NOTE ********************************
// Due to circular package dependencies with the storagetest sub package and
-// the fact that we want to use that harness to test file storage, the tests
-// for file storage are done in the storagetest package.
+// the fact that we want to use that harness to test file storage, most of
+// the tests for file storage are done in the storagetest package.
+
+func TestPathBuilders(t *testing.T) {
+ fs := FileStorage{Path: "test"}
+
+ for i, testcase := range []struct {
+ in, folder, certFile, keyFile, metaFile string
+ }{
+ {
+ in: "example.com",
+ folder: filepath.Join("test", "sites", "example.com"),
+ certFile: filepath.Join("test", "sites", "example.com", "example.com.crt"),
+ keyFile: filepath.Join("test", "sites", "example.com", "example.com.key"),
+ metaFile: filepath.Join("test", "sites", "example.com", "example.com.json"),
+ },
+ {
+ in: "*.example.com",
+ folder: filepath.Join("test", "sites", "wildcard_.example.com"),
+ certFile: filepath.Join("test", "sites", "wildcard_.example.com", "wildcard_.example.com.crt"),
+ keyFile: filepath.Join("test", "sites", "wildcard_.example.com", "wildcard_.example.com.key"),
+ metaFile: filepath.Join("test", "sites", "wildcard_.example.com", "wildcard_.example.com.json"),
+ },
+ {
+ // prevent directory traversal! very important, esp. with on-demand TLS
+ // see issue #2092
+ in: "a/../../../foo",
+ folder: filepath.Join("test", "sites", "afoo"),
+ certFile: filepath.Join("test", "sites", "afoo", "afoo.crt"),
+ keyFile: filepath.Join("test", "sites", "afoo", "afoo.key"),
+ metaFile: filepath.Join("test", "sites", "afoo", "afoo.json"),
+ },
+ {
+ in: "b\\..\\..\\..\\foo",
+ folder: filepath.Join("test", "sites", "bfoo"),
+ certFile: filepath.Join("test", "sites", "bfoo", "bfoo.crt"),
+ keyFile: filepath.Join("test", "sites", "bfoo", "bfoo.key"),
+ metaFile: filepath.Join("test", "sites", "bfoo", "bfoo.json"),
+ },
+ {
+ in: "c/foo",
+ folder: filepath.Join("test", "sites", "cfoo"),
+ certFile: filepath.Join("test", "sites", "cfoo", "cfoo.crt"),
+ keyFile: filepath.Join("test", "sites", "cfoo", "cfoo.key"),
+ metaFile: filepath.Join("test", "sites", "cfoo", "cfoo.json"),
+ },
+ } {
+ if actual := fs.site(testcase.in); actual != testcase.folder {
+ t.Errorf("Test %d: site folder: Expected '%s' but got '%s'", i, testcase.folder, actual)
+ }
+ if actual := fs.siteCertFile(testcase.in); actual != testcase.certFile {
+ t.Errorf("Test %d: site cert file: Expected '%s' but got '%s'", i, testcase.certFile, actual)
+ }
+ if actual := fs.siteKeyFile(testcase.in); actual != testcase.keyFile {
+ t.Errorf("Test %d: site key file: Expected '%s' but got '%s'", i, testcase.keyFile, actual)
+ }
+ if actual := fs.siteMetaFile(testcase.in); actual != testcase.metaFile {
+ t.Errorf("Test %d: site meta file: Expected '%s' but got '%s'", i, testcase.metaFile, actual)
+ }
+ }
+}
diff --git a/caddytls/filestoragesync.go b/caddytls/filestoragesync.go
index a8e7b9291..251f8861f 100644
--- a/caddytls/filestoragesync.go
+++ b/caddytls/filestoragesync.go
@@ -91,7 +91,20 @@ func (s *fileStorageLock) Unlock(name string) error {
if !ok {
return fmt.Errorf("FileStorage: no lock to release for %s", name)
}
+ // remove lock file
os.Remove(fw.filename)
+
+ // if parent folder is now empty, remove it too to keep it tidy
+ lockParentFolder := s.storage.site(name)
+ dir, err := os.Open(lockParentFolder)
+ if err == nil {
+ items, _ := dir.Readdirnames(3) // OK to ignore error here
+ if len(items) == 0 {
+ os.Remove(lockParentFolder)
+ }
+ dir.Close()
+ }
+
fw.wg.Done()
delete(fileStorageNameLocks, s.caURL+name)
return nil
diff --git a/caddytls/handshake.go b/caddytls/handshake.go
index 077ec6323..f507b9029 100644
--- a/caddytls/handshake.go
+++ b/caddytls/handshake.go
@@ -61,10 +61,9 @@ func (cg configGroup) getConfig(name string) *Config {
}
}
- // try a config that serves all names (this
- // is basically the same as a config defined
- // for "*" -- I think -- but the above loop
- // doesn't try an empty string)
+ // try a config that serves all names (the above
+ // loop doesn't try empty string; for hosts defined
+ // with only a port, for instance, like ":443")
if config, ok := cg[""]; ok {
return config
}
@@ -190,17 +189,19 @@ func (cfg *Config) getCertificate(name string) (cert Certificate, matched, defau
return
}
- // if nothing matches and SNI was not provided, use a random
- // certificate; at least there's a chance this older client
- // can connect, and in the future we won't need this provision
- // (if SNI is present, it's probably best to just raise a TLS
- // alert by not serving a certificate)
- if name == "" {
- for _, certKey := range cfg.Certificates {
- defaulted = true
- cert = cfg.certCache.cache[certKey]
- return
- }
+ // if nothing matches, use a random certificate
+ // TODO: This is not my favorite behavior; I would rather serve
+ // no certificate if SNI is provided and cause a TLS alert, than
+ // serve the wrong certificate (but sometimes the 'wrong' cert
+ // is what is wanted, but in those cases I would prefer that the
+ // site owner explicitly configure a "default" certificate).
+ // (See issue 2035; any change to this behavior must account for
+ // hosts defined like ":443" or "0.0.0.0:443" where the hostname
+ // is empty or a catch-all IP or something.)
+ for _, certKey := range cfg.Certificates {
+ cert = cfg.certCache.cache[certKey]
+ defaulted = true
+ return
}
return
diff --git a/caddytls/handshake_test.go b/caddytls/handshake_test.go
index f0b8f7be2..bf427d245 100644
--- a/caddytls/handshake_test.go
+++ b/caddytls/handshake_test.go
@@ -27,7 +27,7 @@ func TestGetCertificate(t *testing.T) {
hello := &tls.ClientHelloInfo{ServerName: "example.com"}
helloSub := &tls.ClientHelloInfo{ServerName: "sub.example.com"}
helloNoSNI := &tls.ClientHelloInfo{}
- helloNoMatch := &tls.ClientHelloInfo{ServerName: "nomatch"}
+ // helloNoMatch := &tls.ClientHelloInfo{ServerName: "nomatch"} // TODO (see below)
// When cache is empty
if cert, err := cfg.GetCertificate(hello); err == nil {
@@ -69,8 +69,9 @@ func TestGetCertificate(t *testing.T) {
t.Errorf("Expected random cert with no matches, got: %v", cert)
}
+ // TODO: Re-implement this behavior (it was reverted in #2037)
// When no certificate matches, raise an alert
- if _, err := cfg.GetCertificate(helloNoMatch); err == nil {
- t.Errorf("Expected an error when no certificate matched the SNI, got: %v", err)
- }
+ // if _, err := cfg.GetCertificate(helloNoMatch); err == nil {
+ // t.Errorf("Expected an error when no certificate matched the SNI, got: %v", err)
+ // }
}
diff --git a/caddytls/httphandler.go b/caddytls/httphandler.go
index 663e2eb02..75ec2cc3c 100644
--- a/caddytls/httphandler.go
+++ b/caddytls/httphandler.go
@@ -16,12 +16,16 @@ package caddytls
import (
"crypto/tls"
+ "encoding/json"
"fmt"
"log"
"net/http"
"net/http/httputil"
"net/url"
+ "os"
"strings"
+
+ "github.com/xenolf/lego/acmev2"
)
const challengeBasePath = "/.well-known/acme-challenge"
@@ -38,6 +42,13 @@ func HTTPChallengeHandler(w http.ResponseWriter, r *http.Request, listenHost str
if DisableHTTPChallenge {
return false
}
+
+ // see if another instance started the HTTP challenge for this name
+ if tryDistributedChallengeSolver(w, r) {
+ return true
+ }
+
+ // otherwise, if we aren't getting the name, then ignore this challenge
if !namesObtaining.Has(r.Host) {
return false
}
@@ -70,3 +81,40 @@ func HTTPChallengeHandler(w http.ResponseWriter, r *http.Request, listenHost str
return true
}
+
+// tryDistributedChallengeSolver checks to see if this challenge
+// request was initiated by another instance that shares file
+// storage, and attempts to complete the challenge for it. It
+// returns true if the challenge was handled; false otherwise.
+func tryDistributedChallengeSolver(w http.ResponseWriter, r *http.Request) bool {
+ filePath := distributedHTTPSolver{}.challengeTokensPath(r.Host)
+ f, err := os.Open(filePath)
+ if err != nil {
+ if !os.IsNotExist(err) {
+ log.Printf("[ERROR][%s] Opening distributed challenge token file: %v", r.Host, err)
+ }
+ return false
+ }
+ defer f.Close()
+
+ var chalInfo challengeInfo
+ err = json.NewDecoder(f).Decode(&chalInfo)
+ if err != nil {
+ log.Printf("[ERROR][%s] Decoding challenge token file %s (corrupted?): %v", r.Host, filePath, err)
+ return false
+ }
+
+ // this part borrowed from xenolf/lego's built-in HTTP-01 challenge solver (March 2018)
+ challengeReqPath := acme.HTTP01ChallengePath(chalInfo.Token)
+ if r.URL.Path == challengeReqPath &&
+ strings.HasPrefix(r.Host, chalInfo.Domain) &&
+ r.Method == "GET" {
+ w.Header().Add("Content-Type", "text/plain")
+ w.Write([]byte(chalInfo.KeyAuth))
+ r.Close = true
+ log.Printf("[INFO][%s] Served key authentication", chalInfo.Domain)
+ return true
+ }
+
+ return false
+}
diff --git a/caddytls/maintain.go b/caddytls/maintain.go
index 5e867d4b8..b24b62125 100644
--- a/caddytls/maintain.go
+++ b/caddytls/maintain.go
@@ -334,6 +334,7 @@ func DeleteOldStapleFiles() {
if err != nil {
log.Printf("[ERROR] Purging corrupt staple file %s: %v", stapleFile, err)
}
+ continue
}
if time.Now().After(resp.NextUpdate) {
// response has expired; delete it
diff --git a/caddytls/setup.go b/caddytls/setup.go
index ef29ed2e0..bcf0cf901 100644
--- a/caddytls/setup.go
+++ b/caddytls/setup.go
@@ -107,19 +107,19 @@ func setupTLS(c *caddy.Controller) error {
case "protocols":
args := c.RemainingArgs()
if len(args) == 1 {
- value, ok := supportedProtocols[strings.ToLower(args[0])]
+ value, ok := SupportedProtocols[strings.ToLower(args[0])]
if !ok {
return c.Errf("Wrong protocol name or protocol not supported: '%s'", args[0])
}
config.ProtocolMinVersion, config.ProtocolMaxVersion = value, value
} else {
- value, ok := supportedProtocols[strings.ToLower(args[0])]
+ value, ok := SupportedProtocols[strings.ToLower(args[0])]
if !ok {
return c.Errf("Wrong protocol name or protocol not supported: '%s'", args[0])
}
config.ProtocolMinVersion = value
- value, ok = supportedProtocols[strings.ToLower(args[1])]
+ value, ok = SupportedProtocols[strings.ToLower(args[1])]
if !ok {
return c.Errf("Wrong protocol name or protocol not supported: '%s'", args[1])
}
@@ -130,7 +130,7 @@ func setupTLS(c *caddy.Controller) error {
}
case "ciphers":
for c.NextArg() {
- value, ok := supportedCiphersMap[strings.ToUpper(c.Val())]
+ value, ok := SupportedCiphersMap[strings.ToUpper(c.Val())]
if !ok {
return c.Errf("Wrong cipher name or cipher not supported: '%s'", c.Val())
}
@@ -210,8 +210,21 @@ func setupTLS(c *caddy.Controller) error {
}
case "must_staple":
config.MustStaple = true
+ case "wildcard":
+ if !HostQualifies(config.Hostname) {
+ return c.Errf("Hostname '%s' does not qualify for managed TLS, so cannot manage wildcard certificate for it", config.Hostname)
+ }
+ if strings.Contains(config.Hostname, "*") {
+ return c.Errf("Cannot convert domain name '%s' to a valid wildcard: already has a wildcard label", config.Hostname)
+ }
+ parts := strings.Split(config.Hostname, ".")
+ if len(parts) < 3 {
+ return c.Errf("Cannot convert domain name '%s' to a valid wildcard: too few labels", config.Hostname)
+ }
+ parts[0] = "*"
+ config.Hostname = strings.Join(parts, ".")
default:
- return c.Errf("Unknown keyword '%s'", c.Val())
+ return c.Errf("Unknown subdirective '%s'", c.Val())
}
}
diff --git a/caddytls/setup_test.go b/caddytls/setup_test.go
index b93b1fc5f..c961939e0 100644
--- a/caddytls/setup_test.go
+++ b/caddytls/setup_test.go
@@ -22,7 +22,7 @@ import (
"testing"
"github.com/mholt/caddy"
- "github.com/xenolf/lego/acme"
+ "github.com/xenolf/lego/acmev2"
)
func TestMain(m *testing.M) {
@@ -67,8 +67,8 @@ func TestSetupParseBasic(t *testing.T) {
}
// Security defaults
- if cfg.ProtocolMinVersion != tls.VersionTLS11 {
- t.Errorf("Expected 'tls1.1 (0x0302)' as ProtocolMinVersion, got %#v", cfg.ProtocolMinVersion)
+ if cfg.ProtocolMinVersion != tls.VersionTLS12 {
+ t.Errorf("Expected 'tls1.2 (0x0303)' as ProtocolMinVersion, got %#v", cfg.ProtocolMinVersion)
}
if cfg.ProtocolMaxVersion != tls.VersionTLS12 {
t.Errorf("Expected 'tls1.2 (0x0303)' as ProtocolMaxVersion, got %v", cfg.ProtocolMaxVersion)
diff --git a/caddytls/storage.go b/caddytls/storage.go
index 05606ed92..c0bc5bc86 100644
--- a/caddytls/storage.go
+++ b/caddytls/storage.go
@@ -58,7 +58,8 @@ type Locker interface {
// successfully obtained the lock (no Waiter value was returned)
// should call this method, and it should be called only after
// the obtain/renew and store are finished, even if there was
- // an error (or a timeout).
+ // an error (or a timeout). Unlock should also clean up any
+ // unused resources allocated during TryLock.
Unlock(name string) error
}
diff --git a/caddytls/tls.go b/caddytls/tls.go
index bf1a8301e..206908892 100644
--- a/caddytls/tls.go
+++ b/caddytls/tls.go
@@ -30,26 +30,35 @@ package caddytls
import (
"encoding/json"
+ "fmt"
+ "io/ioutil"
+ "log"
"net"
+ "os"
+ "path/filepath"
"strings"
"github.com/mholt/caddy"
- "github.com/xenolf/lego/acme"
+ "github.com/xenolf/lego/acmev2"
)
// HostQualifies returns true if the hostname alone
-// appears eligible for automatic HTTPS. For example,
+// appears eligible for automatic HTTPS. For example:
// localhost, empty hostname, and IP addresses are
// not eligible because we cannot obtain certificates
-// for those names.
+// for those names. Wildcard names are allowed, as long
+// as they conform to CABF requirements (only one wildcard
+// label, and it must be the left-most label).
func HostQualifies(hostname string) bool {
return hostname != "localhost" && // localhost is ineligible
// hostname must not be empty
strings.TrimSpace(hostname) != "" &&
- // must not contain wildcard (*) characters (until CA supports it)
- !strings.Contains(hostname, "*") &&
+ // only one wildcard label allowed, and it must be left-most
+ (!strings.Contains(hostname, "*") ||
+ (strings.Count(hostname, "*") == 1 &&
+ strings.HasPrefix(hostname, "*."))) &&
// must not start or end with a dot
!strings.HasPrefix(hostname, ".") &&
@@ -88,39 +97,125 @@ func Revoke(host string) error {
return client.Revoke(host)
}
-// tlsSNISolver is a type that can solve TLS-SNI challenges using
-// an existing listener and our custom, in-memory certificate cache.
-type tlsSNISolver struct {
- certCache *certificateCache
+// TODO: tls-sni challenge was removed in January 2018, but a variant of it might return
+// // tlsSNISolver is a type that can solve TLS-SNI challenges using
+// // an existing listener and our custom, in-memory certificate cache.
+// type tlsSNISolver struct {
+// certCache *certificateCache
+// }
+
+// // Present adds the challenge certificate to the cache.
+// func (s tlsSNISolver) Present(domain, token, keyAuth string) error {
+// cert, acmeDomain, err := acme.TLSSNI01ChallengeCert(keyAuth)
+// if err != nil {
+// return err
+// }
+// certHash := hashCertificateChain(cert.Certificate)
+// s.certCache.Lock()
+// s.certCache.cache[acmeDomain] = Certificate{
+// Certificate: cert,
+// Names: []string{acmeDomain},
+// Hash: certHash, // perhaps not necesssary
+// }
+// s.certCache.Unlock()
+// return nil
+// }
+
+// // CleanUp removes the challenge certificate from the cache.
+// func (s tlsSNISolver) CleanUp(domain, token, keyAuth string) error {
+// _, acmeDomain, err := acme.TLSSNI01ChallengeCert(keyAuth)
+// if err != nil {
+// return err
+// }
+// s.certCache.Lock()
+// delete(s.certCache.cache, acmeDomain)
+// s.certCache.Unlock()
+// return nil
+// }
+
+// distributedHTTPSolver allows the HTTP-01 challenge to be solved by
+// an instance other than the one which initiated it. This is useful
+// behind load balancers or in other cluster/fleet configurations.
+// The only requirement is that this (the initiating) instance share
+// the $CADDYPATH/acme folder with the instance that will complete
+// the challenge. Mounting the folder locally should be sufficient.
+//
+// Obviously, the instance which completes the challenge must be
+// serving on the HTTPChallengePort to receive and handle the request.
+// The HTTP server which receives it must check if a file exists, e.g.:
+// $CADDYPATH/acme/challenge_tokens/example.com.json, and if so,
+// decode it and use it to serve up the correct response. Caddy's HTTP
+// server does this by default.
+//
+// So as long as the folder is shared, this will just work. There are
+// no other requirements. The instances may be on other machines or
+// even other networks, as long as they share the folder as part of
+// the local file system.
+//
+// This solver works by persisting the token and keyauth information
+// to disk in the shared folder when the authorization is presented,
+// and then deletes it when it is cleaned up.
+type distributedHTTPSolver struct {
+ // The distributed HTTPS solver only works if an instance (either
+ // this one or another one) is already listening and serving on the
+ // HTTPChallengePort. If not -- for example: if this is the only
+ // instance, and it is just starting up and hasn't started serving
+ // yet -- then we still need a listener open with an HTTP server
+ // to handle the challenge request. Set this field to have the
+ // standard HTTPProviderServer open its listener for the duration
+ // of the challenge. Make sure to configure its listen address
+ // correctly.
+ httpProviderServer *acme.HTTPProviderServer
+}
+
+type challengeInfo struct {
+ Domain, Token, KeyAuth string
}
// Present adds the challenge certificate to the cache.
-func (s tlsSNISolver) Present(domain, token, keyAuth string) error {
- cert, acmeDomain, err := acme.TLSSNI01ChallengeCert(keyAuth)
+func (dhs distributedHTTPSolver) Present(domain, token, keyAuth string) error {
+ if dhs.httpProviderServer != nil {
+ err := dhs.httpProviderServer.Present(domain, token, keyAuth)
+ if err != nil {
+ return fmt.Errorf("presenting with standard HTTP provider server: %v", err)
+ }
+ }
+
+ err := os.MkdirAll(dhs.challengeTokensBasePath(), 0755)
if err != nil {
return err
}
- certHash := hashCertificateChain(cert.Certificate)
- s.certCache.Lock()
- s.certCache.cache[acmeDomain] = Certificate{
- Certificate: cert,
- Names: []string{acmeDomain},
- Hash: certHash, // perhaps not necesssary
+
+ infoBytes, err := json.Marshal(challengeInfo{
+ Domain: domain,
+ Token: token,
+ KeyAuth: keyAuth,
+ })
+ if err != nil {
+ return err
}
- s.certCache.Unlock()
- return nil
+
+ return ioutil.WriteFile(dhs.challengeTokensPath(domain), infoBytes, 0644)
}
// CleanUp removes the challenge certificate from the cache.
-func (s tlsSNISolver) CleanUp(domain, token, keyAuth string) error {
- _, acmeDomain, err := acme.TLSSNI01ChallengeCert(keyAuth)
- if err != nil {
- return err
+func (dhs distributedHTTPSolver) CleanUp(domain, token, keyAuth string) error {
+ if dhs.httpProviderServer != nil {
+ err := dhs.httpProviderServer.CleanUp(domain, token, keyAuth)
+ if err != nil {
+ log.Printf("[ERROR] Cleaning up standard HTTP provider server: %v", err)
+ }
}
- s.certCache.Lock()
- delete(s.certCache.cache, acmeDomain)
- s.certCache.Unlock()
- return nil
+ return os.Remove(dhs.challengeTokensPath(domain))
+}
+
+func (dhs distributedHTTPSolver) challengeTokensPath(domain string) string {
+ domainFile := strings.Replace(strings.ToLower(domain), "*", "wildcard_", -1)
+ return filepath.Join(dhs.challengeTokensBasePath(), domainFile+".json")
+}
+
+func (dhs distributedHTTPSolver) challengeTokensBasePath() string {
+ return filepath.Join(caddy.AssetsPath(), "acme", "challenge_tokens")
}
// ConfigHolder is any type that has a Config; it presumably is
diff --git a/caddytls/tls_test.go b/caddytls/tls_test.go
index 2b592cf56..0d06f1adb 100644
--- a/caddytls/tls_test.go
+++ b/caddytls/tls_test.go
@@ -18,7 +18,7 @@ import (
"os"
"testing"
- "github.com/xenolf/lego/acme"
+ "github.com/xenolf/lego/acmev2"
)
func TestHostQualifies(t *testing.T) {
@@ -37,7 +37,10 @@ func TestHostQualifies(t *testing.T) {
{"0.0.0.0", false},
{"", false},
{" ", false},
- {"*.example.com", false},
+ {"*.example.com", true},
+ {"*.*.example.com", false},
+ {"sub.*.example.com", false},
+ {"*sub.example.com", false},
{".com", false},
{"example.com.", false},
{"localhost", false},
@@ -77,7 +80,10 @@ func TestQualifiesForManagedTLS(t *testing.T) {
{holder{host: "localhost", cfg: new(Config)}, false},
{holder{host: "123.44.3.21", cfg: new(Config)}, false},
{holder{host: "example.com", cfg: new(Config)}, true},
- {holder{host: "*.example.com", cfg: new(Config)}, false},
+ {holder{host: "*.example.com", cfg: new(Config)}, true},
+ {holder{host: "*.*.example.com", cfg: new(Config)}, false},
+ {holder{host: "*sub.example.com", cfg: new(Config)}, false},
+ {holder{host: "sub.*.example.com", cfg: new(Config)}, false},
{holder{host: "example.com", cfg: &Config{Manual: true}}, false},
{holder{host: "example.com", cfg: &Config{ACMEEmail: "off"}}, false},
{holder{host: "example.com", cfg: &Config{ACMEEmail: "foo@bar.com"}}, true},
diff --git a/caddytls/user.go b/caddytls/user.go
index db6f73215..35f00f0ab 100644
--- a/caddytls/user.go
+++ b/caddytls/user.go
@@ -27,7 +27,7 @@ import (
"os"
"strings"
- "github.com/xenolf/lego/acme"
+ "github.com/xenolf/lego/acmev2"
)
// User represents a Let's Encrypt user account.
@@ -67,43 +67,82 @@ func newUser(email string) (User, error) {
return user, nil
}
-// getEmail does everything it can to obtain an email
-// address from the user within the scope of storage
-// to use for ACME TLS. If it cannot get an email
-// address, it returns empty string. (It will warn the
-// user of the consequences of an empty email.) This
-// function MAY prompt the user for input. If userPresent
-// is false, the operator will NOT be prompted and an
-// empty email may be returned.
-func getEmail(storage Storage, userPresent bool) string {
+// getEmail does everything it can to obtain an email address
+// from the user within the scope of memory and storage to use
+// for ACME TLS. If it cannot get an email address, it returns
+// empty string. (If user is present, it will warn the user of
+// the consequences of an empty email.) This function MAY prompt
+// the user for input. If userPresent is false, the operator
+// will NOT be prompted and an empty email may be returned.
+// If the user is prompted, a new User will be created and
+// stored in storage according to the email address they
+// provided (which might be blank).
+func getEmail(cfg *Config, userPresent bool) (string, error) {
+ storage, err := cfg.StorageFor(cfg.CAUrl)
+ if err != nil {
+ return "", err
+ }
+
// First try memory (command line flag or typed by user previously)
leEmail := DefaultEmail
+
+ // Then try to get most recent user email from storage
if leEmail == "" {
- // Then try to get most recent user email
leEmail = storage.MostRecentUserEmail()
- // Save for next time
- DefaultEmail = leEmail
+ DefaultEmail = leEmail // save for next time
}
+
+ // Looks like there is no email address readily available,
+ // so we will have to ask the user if we can.
if leEmail == "" && userPresent {
- // Alas, we must bother the user and ask for an email address;
- // if they proceed they also agree to the SA.
- reader := bufio.NewReader(stdin)
- fmt.Println("\nYour sites will be served over HTTPS automatically using Let's Encrypt.")
- fmt.Println("By continuing, you agree to the Let's Encrypt Subscriber Agreement at:")
- fmt.Println(" " + saURL) // TODO: Show current SA link
- fmt.Println("Please enter your email address so you can recover your account if needed.")
- fmt.Println("You can leave it blank, but you'll lose the ability to recover your account.")
- fmt.Print("Email address: ")
- var err error
- leEmail, err = reader.ReadString('\n')
+ // evidently, no User data was present in storage;
+ // thus we must make a new User so that we can get
+ // the Terms of Service URL via our ACME client, phew!
+ user, err := newUser("")
if err != nil {
- return ""
+ return "", err
+ }
+
+ // get the agreement URL
+ agreementURL := agreementTestURL
+ if agreementURL == "" {
+ // we call acme.NewClient directly because newACMEClient
+ // would require that we already know the user's email
+ caURL := DefaultCAUrl
+ if cfg.CAUrl != "" {
+ caURL = cfg.CAUrl
+ }
+ tempClient, err := acme.NewClient(caURL, user, "")
+ if err != nil {
+ return "", fmt.Errorf("making ACME client to get ToS URL: %v", err)
+ }
+ agreementURL = tempClient.GetToSURL()
+ }
+
+ // prompt the user for an email address and terms agreement
+ reader := bufio.NewReader(stdin)
+ promptUserAgreement(agreementURL)
+ fmt.Println("Please enter your email address to signify agreement and to be notified")
+ fmt.Println("in case of issues. You can leave it blank, but we don't recommend it.")
+ fmt.Print(" Email address: ")
+ leEmail, err = reader.ReadString('\n')
+ if err != nil && err != io.EOF {
+ return "", fmt.Errorf("reading email address: %v", err)
}
leEmail = strings.TrimSpace(leEmail)
DefaultEmail = leEmail
Agreed = true
+
+ // save the new user to preserve this for next time
+ user.Email = leEmail
+ err = saveUser(storage, user)
+ if err != nil {
+ return "", err
+ }
}
- return strings.ToLower(leEmail)
+
+ // lower-casing the email is important for consistency
+ return strings.ToLower(leEmail), nil
}
// getUser loads the user with the given email from disk
@@ -154,18 +193,21 @@ func saveUser(storage Storage, user User) error {
return err
}
-// promptUserAgreement prompts the user to agree to the agreement
-// at agreementURL via stdin. If the agreement has changed, then pass
-// true as the second argument. If this is the user's first time
-// agreeing, pass false. It returns whether the user agreed or not.
-func promptUserAgreement(agreementURL string, changed bool) bool {
- if changed {
- fmt.Printf("The Let's Encrypt Subscriber Agreement has changed:\n %s\n", agreementURL)
- fmt.Print("Do you agree to the new terms? (y/n): ")
- } else {
- fmt.Printf("To continue, you must agree to the Let's Encrypt Subscriber Agreement:\n %s\n", agreementURL)
- fmt.Print("Do you agree to the terms? (y/n): ")
- }
+// promptUserAgreement simply outputs the standard user
+// agreement prompt with the given agreement URL.
+// It outputs a newline after the message.
+func promptUserAgreement(agreementURL string) {
+ const userAgreementPrompt = `Your sites will be served over HTTPS automatically using Let's Encrypt.
+By continuing, you agree to the Let's Encrypt Subscriber Agreement at:`
+ fmt.Printf("\n\n%s\n %s\n", userAgreementPrompt, agreementURL)
+}
+
+// askUserAgreement prompts the user to agree to the agreement
+// at the given agreement URL via stdin. It returns whether the
+// user agreed or not.
+func askUserAgreement(agreementURL string) bool {
+ promptUserAgreement(agreementURL)
+ fmt.Print("Do you agree to the terms? (y/n): ")
reader := bufio.NewReader(stdin)
answer, err := reader.ReadString('\n')
@@ -177,14 +219,15 @@ func promptUserAgreement(agreementURL string, changed bool) bool {
return answer == "y" || answer == "yes"
}
+// agreementTestURL is set during tests to skip requiring
+// setting up an entire ACME CA endpoint.
+var agreementTestURL string
+
// stdin is used to read the user's input if prompted;
// this is changed by tests during tests.
var stdin = io.ReadWriter(os.Stdin)
// The name of the folder for accounts where the email
-// address was not provided; default 'username' if you will.
+// address was not provided; default 'username' if you will,
+// but only for local/storage use, not with the CA.
const emptyEmail = "default"
-
-// TODO: After Boulder implements the 'meta' field of the directory,
-// we can get this link dynamically.
-const saURL = "https://acme-v01.api.letsencrypt.org/terms"
diff --git a/caddytls/user_test.go b/caddytls/user_test.go
index f82480fbd..68d3d2aa6 100644
--- a/caddytls/user_test.go
+++ b/caddytls/user_test.go
@@ -20,13 +20,14 @@ import (
"crypto/elliptic"
"crypto/rand"
"io"
+ "path/filepath"
"strings"
"testing"
"time"
"os"
- "github.com/xenolf/lego/acme"
+ "github.com/xenolf/lego/acmev2"
)
func TestUser(t *testing.T) {
@@ -135,7 +136,13 @@ func TestGetUserAlreadyExists(t *testing.T) {
}
func TestGetEmail(t *testing.T) {
- storageBasePath = testStorage.Path // to contain calls that create a new Storage...
+ // ensure storage (via StorageFor) uses the local testdata folder that we delete later
+ origCaddypath := os.Getenv("CADDYPATH")
+ os.Setenv("CADDYPATH", "./testdata")
+ defer os.Setenv("CADDYPATH", origCaddypath)
+
+ agreementTestURL = "(none - testing)"
+ defer func() { agreementTestURL = "" }()
// let's not clutter up the output
origStdout := os.Stdout
@@ -146,7 +153,10 @@ func TestGetEmail(t *testing.T) {
DefaultEmail = "test2@foo.com"
// Test1: Use default email from flag (or user previously typing it)
- actual := getEmail(testStorage, true)
+ actual, err := getEmail(testConfig, true)
+ if err != nil {
+ t.Fatalf("getEmail (1) error: %v", err)
+ }
if actual != DefaultEmail {
t.Errorf("Did not get correct email from memory; expected '%s' but got '%s'", DefaultEmail, actual)
}
@@ -154,16 +164,19 @@ func TestGetEmail(t *testing.T) {
// Test2: Get input from user
DefaultEmail = ""
stdin = new(bytes.Buffer)
- _, err := io.Copy(stdin, strings.NewReader("test3@foo.com\n"))
+ _, err = io.Copy(stdin, strings.NewReader("test3@foo.com\n"))
if err != nil {
t.Fatalf("Could not simulate user input, error: %v", err)
}
- actual = getEmail(testStorage, true)
+ actual, err = getEmail(testConfig, true)
+ if err != nil {
+ t.Fatalf("getEmail (2) error: %v", err)
+ }
if actual != "test3@foo.com" {
t.Errorf("Did not get correct email from user input prompt; expected '%s' but got '%s'", "test3@foo.com", actual)
}
- // Test3: Get most recent email from before
+ // Test3: Get most recent email from before (in storage)
DefaultEmail = ""
for i, eml := range []string{
"TEST4-3@foo.com", // test case insensitivity
@@ -189,14 +202,20 @@ func TestGetEmail(t *testing.T) {
t.Fatalf("Could not change user folder mod time for '%s': %v", eml, err)
}
}
- actual = getEmail(testStorage, true)
+ actual, err = getEmail(testConfig, true)
+ if err != nil {
+ t.Fatalf("getEmail (3) error: %v", err)
+ }
if actual != "test4-3@foo.com" {
t.Errorf("Did not get correct email from storage; expected '%s' but got '%s'", "test4-3@foo.com", actual)
}
}
-var testStorage = &FileStorage{Path: "./testdata"}
+var (
+ testStorageBase = "./testdata" // ephemeral folder that gets deleted after tests finish
+ testCAHost = "localhost"
+ testConfig = &Config{CAUrl: "http://" + testCAHost + "/directory", StorageProvider: "file"}
+ testStorage = &FileStorage{Path: filepath.Join(testStorageBase, "acme", testCAHost)}
+)
-func (s *FileStorage) clean() error {
- return os.RemoveAll(s.Path)
-}
+func (s *FileStorage) clean() error { return os.RemoveAll(testStorageBase) }
diff --git a/dist/CHANGES.txt b/dist/CHANGES.txt
index bb805d6f9..15e154854 100644
--- a/dist/CHANGES.txt
+++ b/dist/CHANGES.txt
@@ -1,5 +1,64 @@
CHANGES
+0.10.14 (April 19, 2018)
+- tls: Fix error handling bug when obtaining certificates
+
+
+0.10.13 (April 18, 2018)
+- New third-party plugin: supervisor
+- Updated QUIC
+- proxy: Fix transparent pass-thru of X-Forwarded-For
+- proxy: Configurable timeout to upstream
+- rewrite: Now supports regular expressions on single-line
+- tls: StrictHostMatching mode to prevent client auth bypass
+- tls: Disable client auth when using QUIC
+- tls: Require same client auth cert pools per hostname
+- tls: Prevent On-Demand TLS directory traversal
+- tls: Fix empty files when using ACME fails to obtain cert
+- Fixed test broken by 1.1.1.1 resolving
+- Improved Caddyfile parser robustness by fuzzing
+
+
+0.10.12 (March 27, 2018)
+- Switch to Let's Encrypt ACMEv2 production endpoint
+- Support for automated wildcard certificates
+- Support distributed solving of HTTP-01 challenge
+- New {labelN}, {tls_cipher}, and {tls_version} placeholders
+- Curly braces can now be escaped when not used as placeholders
+- New third-party plugin: geoip
+- Updated QUIC
+- fastcgi: Add SSL_CIPHER and SSL_PROTOCOL environment variables
+- log: New 'except' subdirective to exempt paths from logging
+- startup/shutdown: Removed in favor of 'on'
+- tls: Default minimum version is TLS 1.2
+- tls: Revert to fallback cert if no cert matches SNI
+- tls: New 'wildcard' subdirective to force automated wildcard cert
+- Several significant bug fixes and improvements!
+
+
+0.10.11 (February 20, 2018)
+- Built with Go 1.10
+- Reusable snippets for the Caddyfile
+- Updated QUIC
+- Auto-HTTPS certificates may be shared by multiple instances
+- Expand globbed values in -conf flag
+- Swap behavior of SIGTERM and SIGQUIT; ignore SIGHUP
+- 9 new DNS provider plugins for the ACME DNS challenge
+- New placeholder for { %s), connectionID %x, version %s", hostname, c.conn.LocalAddr().String(), c.conn.RemoteAddr().String(), c.connectionID, c.version)
+ c.logger.Infof("Starting new connection to %s (%s -> %s), connectionID %x, version %s", hostname, c.conn.LocalAddr().String(), c.conn.RemoteAddr().String(), c.connectionID, c.version)
if err := c.dial(); err != nil {
return nil, err
@@ -132,6 +143,18 @@ func populateClientConfig(config *Config) *Config {
if maxReceiveConnectionFlowControlWindow == 0 {
maxReceiveConnectionFlowControlWindow = protocol.DefaultMaxReceiveConnectionFlowControlWindowClient
}
+ maxIncomingStreams := config.MaxIncomingStreams
+ if maxIncomingStreams == 0 {
+ maxIncomingStreams = protocol.DefaultMaxIncomingStreams
+ } else if maxIncomingStreams < 0 {
+ maxIncomingStreams = 0
+ }
+ maxIncomingUniStreams := config.MaxIncomingUniStreams
+ if maxIncomingUniStreams == 0 {
+ maxIncomingUniStreams = protocol.DefaultMaxIncomingUniStreams
+ } else if maxIncomingUniStreams < 0 {
+ maxIncomingUniStreams = 0
+ }
return &Config{
Versions: versions,
@@ -140,7 +163,9 @@ func populateClientConfig(config *Config) *Config {
RequestConnectionIDOmission: config.RequestConnectionIDOmission,
MaxReceiveStreamFlowControlWindow: maxReceiveStreamFlowControlWindow,
MaxReceiveConnectionFlowControlWindow: maxReceiveConnectionFlowControlWindow,
- KeepAlive: config.KeepAlive,
+ MaxIncomingStreams: maxIncomingStreams,
+ MaxIncomingUniStreams: maxIncomingUniStreams,
+ KeepAlive: config.KeepAlive,
}
}
@@ -171,12 +196,11 @@ func (c *client) dialTLS() error {
ConnectionFlowControlWindow: protocol.ReceiveConnectionFlowControlWindow,
IdleTimeout: c.config.IdleTimeout,
OmitConnectionID: c.config.RequestConnectionIDOmission,
- // TODO(#523): make these values configurable
- MaxBidiStreamID: protocol.MaxBidiStreamID(protocol.MaxIncomingStreams, protocol.PerspectiveClient),
- MaxUniStreamID: protocol.MaxUniStreamID(protocol.MaxIncomingStreams, protocol.PerspectiveClient),
+ MaxBidiStreams: uint16(c.config.MaxIncomingStreams),
+ MaxUniStreams: uint16(c.config.MaxIncomingUniStreams),
}
csc := handshake.NewCryptoStreamConn(nil)
- extHandler := handshake.NewExtensionHandlerClient(params, c.initialVersion, c.config.Versions, c.version)
+ extHandler := handshake.NewExtensionHandlerClient(params, c.initialVersion, c.config.Versions, c.version, c.logger)
mintConf, err := tlsToMintConfig(c.tlsConf, protocol.PerspectiveClient)
if err != nil {
return err
@@ -193,7 +217,7 @@ func (c *client) dialTLS() error {
if err != handshake.ErrCloseSessionForRetry {
return err
}
- utils.Infof("Received a Retry packet. Recreating session.")
+ c.logger.Infof("Received a Retry packet. Recreating session.")
if err := c.createNewTLSSession(extHandler.GetPeerParams(), c.version); err != nil {
return err
}
@@ -216,7 +240,7 @@ func (c *client) establishSecureConnection() error {
go func() {
runErr = c.session.run() // returns as soon as the session is closed
close(errorChan)
- utils.Infof("Connection %x closed.", c.connectionID)
+ c.logger.Infof("Connection %x closed.", c.connectionID)
if runErr != handshake.ErrCloseSessionForRetry && runErr != errCloseSessionForNewVersion {
c.conn.Close()
}
@@ -245,7 +269,7 @@ func (c *client) listen() {
for {
var n int
var addr net.Addr
- data := getPacketBuffer()
+ data := *getPacketBuffer()
data = data[:protocol.MaxReceivePacketSize]
// The packet size should not exceed protocol.MaxReceivePacketSize bytes
// If it does, we only read a truncated packet, which will then end up undecryptable
@@ -270,7 +294,7 @@ func (c *client) handlePacket(remoteAddr net.Addr, packet []byte) {
r := bytes.NewReader(packet)
hdr, err := wire.ParseHeaderSentByServer(r, c.version)
if err != nil {
- utils.Errorf("error parsing packet from %s: %s", remoteAddr.String(), err.Error())
+ c.logger.Errorf("error parsing packet from %s: %s", remoteAddr.String(), err.Error())
// drop this packet if we can't parse the header
return
}
@@ -293,15 +317,15 @@ func (c *client) handlePacket(remoteAddr net.Addr, packet []byte) {
// check if the remote address and the connection ID match
// otherwise this might be an attacker trying to inject a PUBLIC_RESET to kill the connection
if cr.Network() != remoteAddr.Network() || cr.String() != remoteAddr.String() || hdr.ConnectionID != c.connectionID {
- utils.Infof("Received a spoofed Public Reset. Ignoring.")
+ c.logger.Infof("Received a spoofed Public Reset. Ignoring.")
return
}
pr, err := wire.ParsePublicReset(r)
if err != nil {
- utils.Infof("Received a Public Reset. An error occurred parsing the packet: %s", err)
+ c.logger.Infof("Received a Public Reset. An error occurred parsing the packet: %s", err)
return
}
- utils.Infof("Received Public Reset, rejected packet number: %#x.", pr.RejectedPacketNumber)
+ c.logger.Infof("Received Public Reset, rejected packet number: %#x.", pr.RejectedPacketNumber)
c.session.closeRemote(qerr.Error(qerr.PublicReset, fmt.Sprintf("Received a Public Reset for packet number %#x", pr.RejectedPacketNumber)))
return
}
@@ -347,6 +371,8 @@ func (c *client) handleVersionNegotiationPacket(hdr *wire.Header) error {
}
}
+ c.logger.Infof("Received a Version Negotiation Packet. Supported Versions: %s", hdr.SupportedVersions)
+
newVersion, ok := protocol.ChooseSupportedVersion(c.config.Versions, hdr.SupportedVersions)
if !ok {
return qerr.InvalidVersion
@@ -362,7 +388,7 @@ func (c *client) handleVersionNegotiationPacket(hdr *wire.Header) error {
if err != nil {
return err
}
- utils.Infof("Switching to QUIC version %s. New connection ID: %x", newVersion, c.connectionID)
+ c.logger.Infof("Switching to QUIC version %s. New connection ID: %x", newVersion, c.connectionID)
c.session.Close(errCloseSessionForNewVersion)
return nil
}
@@ -379,6 +405,7 @@ func (c *client) createNewGQUICSession() (err error) {
c.config,
c.initialVersion,
c.negotiatedVersions,
+ c.logger,
)
return err
}
@@ -398,6 +425,7 @@ func (c *client) createNewTLSSession(
c.tls,
paramsChan,
1,
+ c.logger,
)
return err
}
diff --git a/vendor/github.com/lucas-clemente/quic-go/example/client/main.go b/vendor/github.com/lucas-clemente/quic-go/example/client/main.go
index 2a28c1612..23f045c84 100644
--- a/vendor/github.com/lucas-clemente/quic-go/example/client/main.go
+++ b/vendor/github.com/lucas-clemente/quic-go/example/client/main.go
@@ -19,12 +19,14 @@ func main() {
flag.Parse()
urls := flag.Args()
+ logger := utils.DefaultLogger
+
if *verbose {
- utils.SetLogLevel(utils.LogLevelDebug)
+ logger.SetLogLevel(utils.LogLevelDebug)
} else {
- utils.SetLogLevel(utils.LogLevelInfo)
+ logger.SetLogLevel(utils.LogLevelInfo)
}
- utils.SetLogTimeFormat("")
+ logger.SetLogTimeFormat("")
versions := protocol.SupportedVersions
if *tls {
@@ -42,21 +44,21 @@ func main() {
var wg sync.WaitGroup
wg.Add(len(urls))
for _, addr := range urls {
- utils.Infof("GET %s", addr)
+ logger.Infof("GET %s", addr)
go func(addr string) {
rsp, err := hclient.Get(addr)
if err != nil {
panic(err)
}
- utils.Infof("Got response for %s: %#v", addr, rsp)
+ logger.Infof("Got response for %s: %#v", addr, rsp)
body := &bytes.Buffer{}
_, err = io.Copy(body, rsp.Body)
if err != nil {
panic(err)
}
- utils.Infof("Request Body:")
- utils.Infof("%s", body.Bytes())
+ logger.Infof("Request Body:")
+ logger.Infof("%s", body.Bytes())
wg.Done()
}(addr)
}
diff --git a/vendor/github.com/lucas-clemente/quic-go/example/main.go b/vendor/github.com/lucas-clemente/quic-go/example/main.go
index 35aaa85c6..e83fb8703 100644
--- a/vendor/github.com/lucas-clemente/quic-go/example/main.go
+++ b/vendor/github.com/lucas-clemente/quic-go/example/main.go
@@ -91,7 +91,7 @@ func init() {
}
}
if err != nil {
- utils.Infof("Error receiving upload: %#v", err)
+ utils.DefaultLogger.Infof("Error receiving upload: %#v", err)
}
}
io.WriteString(w, `