From 79de2a5de2187b2682bf98c643d9857de3263afb Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Sat, 26 Sep 2015 18:11:53 -0600 Subject: [PATCH 01/73] Stubbed out basic code to obtain Let's Encrypt cert --- config/config.go | 54 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/config/config.go b/config/config.go index 82a29585..a763d3c2 100644 --- a/config/config.go +++ b/config/config.go @@ -1,6 +1,9 @@ package config import ( + "crypto/rand" + "crypto/rsa" + "errors" "fmt" "io" "log" @@ -11,6 +14,7 @@ import ( "github.com/mholt/caddy/config/setup" "github.com/mholt/caddy/middleware" "github.com/mholt/caddy/server" + "github.com/xenolf/lego/acme" ) const ( @@ -73,10 +77,60 @@ func Load(filename string, input io.Reader) (Group, error) { // restore logging settings log.SetFlags(flags) + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return Group{}, errors.New("Error Generating Key:" + err.Error()) + } + + for _, cfg := range configs { + // TODO: && hostname does not resolve to localhost (?) && TLS is not force-disabled + if !cfg.TLS.Enabled { + // Initiate Let's Encrypt + user := LetsEncryptUser{ + Email: "example@mail.com", + Key: privateKey, + } + client := acme.NewClient("http://192.168.99.100:4000", &user, 2048, "5001") + reg, err := client.Register() + if err != nil { + return Group{}, errors.New("Error Registering: " + err.Error()) + } + user.Registration = reg + + err = client.AgreeToTos() + if err != nil { + return Group{}, errors.New("Error Agreeing to ToS: " + err.Error()) + } + + certs, err := client.ObtainCertificates([]string{"caddy.dev"}) + if err != nil { + return Group{}, errors.New("Error Obtaining Certs: " + err.Error()) + } + + fmt.Printf("%#v\n", certs) + } + } + // Group by address/virtualhosts return arrangeBindings(configs) } +type LetsEncryptUser struct { + Email string + Registration *acme.RegistrationResource + Key *rsa.PrivateKey +} + +func (u LetsEncryptUser) GetEmail() string { + return u.Email +} +func (u LetsEncryptUser) GetRegistration() *acme.RegistrationResource { + return u.Registration +} +func (u LetsEncryptUser) GetPrivateKey() *rsa.PrivateKey { + return u.Key +} + // serverBlockToConfig makes a config for the server block // by executing the tokens that were parsed. The returned // config is shared among all hosts/addresses for the server From 7121e2c77077c177149551080dc5104c6ca6911c Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Mon, 12 Oct 2015 19:17:50 -0600 Subject: [PATCH 02/73] Change c:\go to c:\gopath to avoid conflicts --- appveyor.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/appveyor.yml b/appveyor.yml index 1866debe..a436177c 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -2,10 +2,10 @@ version: "{build}" os: Windows Server 2012 R2 -clone_folder: c:\go\src\github.com\mholt\caddy +clone_folder: c:\gopath\src\github.com\mholt\caddy environment: - GOPATH: c:\go + GOPATH: c:\gopath install: - go get golang.org/x/tools/cmd/vet From 0c07f7adcce7deefd9b163c8c276b90a36af18b7 Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Wed, 14 Oct 2015 23:45:28 -0600 Subject: [PATCH 03/73] Epic revert of 0ac8bf5 and adding OncePerServerBlock Turns out having each server block share a single server.Config during initialization when the Setup functions are being called was a bad idea. Sure, startup and shutdown functions were only executed once, but they had no idea what their hostname or port was. So here we revert to the old way of doing things where Setup may be called multiple times per server block (once per host associated with the block, to be precise), but the Setup functions now know their host and port since the config belongs to exactly one virtualHost. To have something happen just once per server block, use OncePerServerBlock, a new function available on each Controller. --- config/config.go | 113 ++++++++++++----------------- config/parse/parse.go | 2 +- config/parse/parsing.go | 21 +++--- config/parse/parsing_test.go | 135 +++++++++++++++++++++-------------- config/setup/controller.go | 1 + config/setup/fastcgi.go | 2 +- main.go | 7 +- server/server.go | 2 +- 8 files changed, 145 insertions(+), 138 deletions(-) diff --git a/config/config.go b/config/config.go index cc7c5b11..93f5146b 100644 --- a/config/config.go +++ b/config/config.go @@ -5,6 +5,7 @@ import ( "io" "log" "net" + "sync" "github.com/mholt/caddy/app" "github.com/mholt/caddy/config/parse" @@ -40,32 +41,51 @@ func Load(filename string, input io.Reader) (Group, error) { return Default() } - // Each server block represents one or more servers/addresses. + // Each server block represents similar hosts/addresses. // Iterate each server block and make a config for each one, // executing the directives that were parsed. for _, sb := range serverBlocks { - sharedConfig, err := serverBlockToConfig(filename, sb) - if err != nil { - return nil, err - } + var once sync.Once + + for _, addr := range sb.Addresses { + config := server.Config{ + Host: addr.Host, + Port: addr.Port, + Root: Root, + Middleware: make(map[string][]middleware.Middleware), + ConfigFile: filename, + AppName: app.Name, + AppVersion: app.Version, + } + + // It is crucial that directives are executed in the proper order. + for _, dir := range directiveOrder { + // Execute directive if it is in the server block + if tokens, ok := sb.Tokens[dir.name]; ok { + // Each setup function gets a controller, which is the + // server config and the dispenser containing only + // this directive's tokens. + controller := &setup.Controller{ + Config: &config, + Dispenser: parse.NewDispenserTokens(filename, tokens), + OncePerServerBlock: func(f func()) { once.Do(f) }, + } + + midware, err := dir.setup(controller) + if err != nil { + return nil, err + } + if midware != nil { + // TODO: For now, we only support the default path scope / + config.Middleware["/"] = append(config.Middleware["/"], midware) + } + } + } - // Now share the config with as many hosts as share the server block - for i, addr := range sb.Addresses { - config := sharedConfig - config.Host = addr.Host - config.Port = addr.Port if config.Port == "" { config.Port = Port } - if config.Port == "http" { - config.TLS.Enabled = false - log.Printf("Warning: TLS disabled for %s://%s. To force TLS over the plaintext HTTP port, "+ - "specify port 80 explicitly (https://%s:80).", config.Port, config.Host, config.Host) - } - if i == 0 { - sharedConfig.Startup = []func() error{} - sharedConfig.Shutdown = []func() error{} - } + configs = append(configs, config) } } @@ -73,50 +93,9 @@ func Load(filename string, input io.Reader) (Group, error) { // restore logging settings log.SetFlags(flags) - // Group by address/virtualhosts return arrangeBindings(configs) } -// serverBlockToConfig makes a config for the server block -// by executing the tokens that were parsed. The returned -// config is shared among all hosts/addresses for the server -// block, so Host and Port information is not filled out -// here. -func serverBlockToConfig(filename string, sb parse.ServerBlock) (server.Config, error) { - sharedConfig := server.Config{ - Root: Root, - Middleware: make(map[string][]middleware.Middleware), - ConfigFile: filename, - AppName: app.Name, - AppVersion: app.Version, - } - - // It is crucial that directives are executed in the proper order. - for _, dir := range directiveOrder { - // Execute directive if it is in the server block - if tokens, ok := sb.Tokens[dir.name]; ok { - // Each setup function gets a controller, which is the - // server config and the dispenser containing only - // this directive's tokens. - controller := &setup.Controller{ - Config: &sharedConfig, - Dispenser: parse.NewDispenserTokens(filename, tokens), - } - - midware, err := dir.setup(controller) - if err != nil { - return sharedConfig, err - } - if midware != nil { - // TODO: For now, we only support the default path scope / - sharedConfig.Middleware["/"] = append(sharedConfig.Middleware["/"], midware) - } - } - } - - return sharedConfig, nil -} - // arrangeBindings groups configurations by their bind address. For example, // a server that should listen on localhost and another on 127.0.0.1 will // be grouped into the same address: 127.0.0.1. It will return an error @@ -125,8 +104,8 @@ func serverBlockToConfig(filename string, sb parse.ServerBlock) (server.Config, // bind address to list of configs that would become VirtualHosts on that // server. Use the keys of the returned map to create listeners, and use // the associated values to set up the virtualhosts. -func arrangeBindings(allConfigs []server.Config) (Group, error) { - addresses := make(Group) +func arrangeBindings(allConfigs []server.Config) (map[*net.TCPAddr][]server.Config, error) { + addresses := make(map[*net.TCPAddr][]server.Config) // Group configs by bind address for _, conf := range allConfigs { @@ -234,8 +213,9 @@ func validDirective(d string) bool { return false } -// NewDefault creates a default configuration using the default -// root, host, and port. +// NewDefault makes a default configuration, which +// is empty except for root, host, and port, +// which are essentials for serving the cwd. func NewDefault() server.Config { return server.Config{ Root: Root, @@ -244,9 +224,8 @@ func NewDefault() server.Config { } } -// Default makes a default configuration which -// is empty except for root, host, and port, -// which are essentials for serving the cwd. +// Default obtains a default config and arranges +// bindings so it's ready to use. func Default() (Group, error) { return arrangeBindings([]server.Config{NewDefault()}) } diff --git a/config/parse/parse.go b/config/parse/parse.go index dbb62a36..b44041d4 100644 --- a/config/parse/parse.go +++ b/config/parse/parse.go @@ -6,7 +6,7 @@ import "io" // ServerBlocks parses the input just enough to organize tokens, // in order, by server block. No further parsing is performed. // Server blocks are returned in the order in which they appear. -func ServerBlocks(filename string, input io.Reader) ([]ServerBlock, error) { +func ServerBlocks(filename string, input io.Reader) ([]serverBlock, error) { p := parser{Dispenser: NewDispenser(filename, input)} blocks, err := p.parseAll() return blocks, err diff --git a/config/parse/parsing.go b/config/parse/parsing.go index 4fb1f3df..6ec04dce 100644 --- a/config/parse/parsing.go +++ b/config/parse/parsing.go @@ -9,12 +9,12 @@ import ( type parser struct { Dispenser - block ServerBlock // current server block being parsed + block serverBlock // current server block being parsed eof bool // if we encounter a valid EOF in a hard place } -func (p *parser) parseAll() ([]ServerBlock, error) { - var blocks []ServerBlock +func (p *parser) parseAll() ([]serverBlock, error) { + var blocks []serverBlock for p.Next() { err := p.parseOne() @@ -30,7 +30,7 @@ func (p *parser) parseAll() ([]ServerBlock, error) { } func (p *parser) parseOne() error { - p.block = ServerBlock{Tokens: make(map[string][]token)} + p.block = serverBlock{Tokens: make(map[string][]token)} err := p.begin() if err != nil { @@ -87,7 +87,7 @@ func (p *parser) addresses() error { break } - if tkn != "" { + if tkn != "" { // empty token possible if user typed "" in Caddyfile // Trailing comma indicates another address will follow, which // may possibly be on the next line if tkn[len(tkn)-1] == ',' { @@ -102,7 +102,7 @@ func (p *parser) addresses() error { if err != nil { return err } - p.block.Addresses = append(p.block.Addresses, Address{host, port}) + p.block.Addresses = append(p.block.Addresses, address{host, port}) } // Advance token and possibly break out of loop or return error @@ -301,15 +301,14 @@ func standardAddress(str string) (host, port string, err error) { } type ( - // ServerBlock associates tokens with a list of addresses + // serverBlock associates tokens with a list of addresses // and groups tokens by directive name. - ServerBlock struct { - Addresses []Address + serverBlock struct { + Addresses []address Tokens map[string][]token } - // Address represents a host and port. - Address struct { + address struct { Host, Port string } ) diff --git a/config/parse/parsing_test.go b/config/parse/parsing_test.go index 197ec0da..c8a7ef0b 100644 --- a/config/parse/parsing_test.go +++ b/config/parse/parsing_test.go @@ -59,7 +59,7 @@ func TestStandardAddress(t *testing.T) { func TestParseOneAndImport(t *testing.T) { setupParseTests() - testParseOne := func(input string) (ServerBlock, error) { + testParseOne := func(input string) (serverBlock, error) { p := testParser(input) p.Next() // parseOne doesn't call Next() to start, so we must err := p.parseOne() @@ -69,22 +69,22 @@ func TestParseOneAndImport(t *testing.T) { for i, test := range []struct { input string shouldErr bool - addresses []Address + addresses []address tokens map[string]int // map of directive name to number of tokens expected }{ - {`localhost`, false, []Address{ + {`localhost`, false, []address{ {"localhost", ""}, }, map[string]int{}}, {`localhost - dir1`, false, []Address{ + dir1`, false, []address{ {"localhost", ""}, }, map[string]int{ "dir1": 1, }}, {`localhost:1234 - dir1 foo bar`, false, []Address{ + dir1 foo bar`, false, []address{ {"localhost", "1234"}, }, map[string]int{ "dir1": 3, @@ -92,7 +92,7 @@ func TestParseOneAndImport(t *testing.T) { {`localhost { dir1 - }`, false, []Address{ + }`, false, []address{ {"localhost", ""}, }, map[string]int{ "dir1": 1, @@ -101,7 +101,7 @@ func TestParseOneAndImport(t *testing.T) { {`localhost:1234 { dir1 foo bar dir2 - }`, false, []Address{ + }`, false, []address{ {"localhost", "1234"}, }, map[string]int{ "dir1": 3, @@ -109,7 +109,7 @@ func TestParseOneAndImport(t *testing.T) { }}, {`http://localhost https://localhost - dir1 foo bar`, false, []Address{ + dir1 foo bar`, false, []address{ {"localhost", "http"}, {"localhost", "https"}, }, map[string]int{ @@ -118,7 +118,7 @@ func TestParseOneAndImport(t *testing.T) { {`http://localhost https://localhost { dir1 foo bar - }`, false, []Address{ + }`, false, []address{ {"localhost", "http"}, {"localhost", "https"}, }, map[string]int{ @@ -127,7 +127,7 @@ func TestParseOneAndImport(t *testing.T) { {`http://localhost, https://localhost { dir1 foo bar - }`, false, []Address{ + }`, false, []address{ {"localhost", "http"}, {"localhost", "https"}, }, map[string]int{ @@ -135,13 +135,13 @@ func TestParseOneAndImport(t *testing.T) { }}, {`http://localhost, { - }`, true, []Address{ + }`, true, []address{ {"localhost", "http"}, }, map[string]int{}}, {`host1:80, http://host2.com dir1 foo bar - dir2 baz`, false, []Address{ + dir2 baz`, false, []address{ {"host1", "80"}, {"host2.com", "http"}, }, map[string]int{ @@ -151,7 +151,7 @@ func TestParseOneAndImport(t *testing.T) { {`http://host1.com, http://host2.com, - https://host3.com`, false, []Address{ + https://host3.com`, false, []address{ {"host1.com", "http"}, {"host2.com", "http"}, {"host3.com", "https"}, @@ -161,7 +161,7 @@ func TestParseOneAndImport(t *testing.T) { dir1 foo { bar baz } - dir2`, false, []Address{ + dir2`, false, []address{ {"host1.com", "1234"}, {"host2.com", "https"}, }, map[string]int{ @@ -175,7 +175,7 @@ func TestParseOneAndImport(t *testing.T) { } dir2 { foo bar - }`, false, []Address{ + }`, false, []address{ {"127.0.0.1", ""}, }, map[string]int{ "dir1": 5, @@ -183,13 +183,13 @@ func TestParseOneAndImport(t *testing.T) { }}, {`127.0.0.1 - unknown_directive`, true, []Address{ + unknown_directive`, true, []address{ {"127.0.0.1", ""}, }, map[string]int{}}, {`localhost dir1 { - foo`, true, []Address{ + foo`, true, []address{ {"localhost", ""}, }, map[string]int{ "dir1": 3, @@ -197,7 +197,15 @@ func TestParseOneAndImport(t *testing.T) { {`localhost dir1 { - }`, false, []Address{ + }`, false, []address{ + {"localhost", ""}, + }, map[string]int{ + "dir1": 3, + }}, + + {`localhost + dir1 { + } }`, true, []address{ {"localhost", ""}, }, map[string]int{ "dir1": 3, @@ -209,18 +217,18 @@ func TestParseOneAndImport(t *testing.T) { foo } } - dir2 foo bar`, false, []Address{ + dir2 foo bar`, false, []address{ {"localhost", ""}, }, map[string]int{ "dir1": 7, "dir2": 3, }}, - {``, false, []Address{}, map[string]int{}}, + {``, false, []address{}, map[string]int{}}, {`localhost dir1 arg1 - import import_test1.txt`, false, []Address{ + import import_test1.txt`, false, []address{ {"localhost", ""}, }, map[string]int{ "dir1": 2, @@ -228,16 +236,20 @@ func TestParseOneAndImport(t *testing.T) { "dir3": 1, }}, - {`import import_test2.txt`, false, []Address{ + {`import import_test2.txt`, false, []address{ {"host1", ""}, }, map[string]int{ "dir1": 1, "dir2": 2, }}, - {``, false, []Address{}, map[string]int{}}, + {`import import_test1.txt import_test2.txt`, true, []address{}, map[string]int{}}, - {`""`, false, []Address{}, map[string]int{}}, + {`import not_found.txt`, true, []address{}, map[string]int{}}, + + {`""`, false, []address{}, map[string]int{}}, + + {``, false, []address{}, map[string]int{}}, } { result, err := testParseOne(test.input) @@ -282,43 +294,43 @@ func TestParseOneAndImport(t *testing.T) { func TestParseAll(t *testing.T) { setupParseTests() - testParseAll := func(input string) ([]ServerBlock, error) { - p := testParser(input) - return p.parseAll() - } - for i, test := range []struct { input string shouldErr bool - numBlocks int + addresses [][]address // addresses per server block, in order }{ - {`localhost`, false, 1}, + {`localhost`, false, [][]address{ + {{"localhost", ""}}, + }}, - {`localhost { - dir1 - }`, false, 1}, + {`localhost:1234`, false, [][]address{ + []address{{"localhost", "1234"}}, + }}, - {`http://localhost https://localhost - dir1 foo bar`, false, 1}, + {`localhost:1234 { + } + localhost:2015 { + }`, false, [][]address{ + []address{{"localhost", "1234"}}, + []address{{"localhost", "2015"}}, + }}, - {`http://localhost, https://localhost { - dir1 foo bar - }`, false, 1}, + {`localhost:1234, http://host2`, false, [][]address{ + []address{{"localhost", "1234"}, {"host2", "http"}}, + }}, - {`http://host1.com, - http://host2.com, - https://host3.com`, false, 1}, + {`localhost:1234, http://host2,`, true, [][]address{}}, - {`host1 { - } - host2 { - }`, false, 2}, - - {`""`, false, 0}, - - {``, false, 0}, + {`http://host1.com, http://host2.com { + } + https://host3.com, https://host4.com { + }`, false, [][]address{ + []address{{"host1.com", "http"}, {"host2.com", "http"}}, + []address{{"host3.com", "https"}, {"host4.com", "https"}}, + }}, } { - results, err := testParseAll(test.input) + p := testParser(test.input) + blocks, err := p.parseAll() if test.shouldErr && err == nil { t.Errorf("Test %d: Expected an error, but didn't get one", i) @@ -327,11 +339,28 @@ func TestParseAll(t *testing.T) { t.Errorf("Test %d: Expected no error, but got: %v", i, err) } - if len(results) != test.numBlocks { + if len(blocks) != len(test.addresses) { t.Errorf("Test %d: Expected %d server blocks, got %d", - i, test.numBlocks, len(results)) + i, len(test.addresses), len(blocks)) continue } + for j, block := range blocks { + if len(block.Addresses) != len(test.addresses[j]) { + t.Errorf("Test %d: Expected %d addresses in block %d, got %d", + i, len(test.addresses[j]), j, len(block.Addresses)) + continue + } + for k, addr := range block.Addresses { + if addr.Host != test.addresses[j][k].Host { + t.Errorf("Test %d, block %d, address %d: Expected host to be '%s', but was '%s'", + i, j, k, test.addresses[j][k].Host, addr.Host) + } + if addr.Port != test.addresses[j][k].Port { + t.Errorf("Test %d, block %d, address %d: Expected port to be '%s', but was '%s'", + i, j, k, test.addresses[j][k].Port, addr.Port) + } + } + } } } diff --git a/config/setup/controller.go b/config/setup/controller.go index 0399ab81..2974f2b5 100644 --- a/config/setup/controller.go +++ b/config/setup/controller.go @@ -15,6 +15,7 @@ import ( type Controller struct { *server.Config parse.Dispenser + OncePerServerBlock func(f func()) } // NewTestController creates a new *Controller for diff --git a/config/setup/fastcgi.go b/config/setup/fastcgi.go index ab21ef1f..a2a7e879 100644 --- a/config/setup/fastcgi.go +++ b/config/setup/fastcgi.go @@ -31,7 +31,7 @@ func FastCGI(c *Controller) (middleware.Middleware, error) { SoftwareName: c.AppName, SoftwareVersion: c.AppVersion, ServerName: c.Host, - ServerPort: c.Port, // BUG: This is not known until the server blocks are split up... + ServerPort: c.Port, } }, nil } diff --git a/main.go b/main.go index 0740346c..272cb8fc 100644 --- a/main.go +++ b/main.go @@ -49,7 +49,7 @@ func main() { log.Fatal(err) } - // Load address configurations from highest priority input + // Load config from file addresses, err := loadConfigs() if err != nil { log.Fatal(err) @@ -123,10 +123,9 @@ func isLocalhost(s string) bool { // loadConfigs loads configuration from a file or stdin (piped). // The configurations are grouped by bind address. -// Configuration is obtained from one of three sources, tried +// Configuration is obtained from one of four sources, tried // in this order: 1. -conf flag, 2. stdin, 3. command line argument 4. Caddyfile. -// If none of those are available, a default configuration is -// loaded. +// If none of those are available, a default configuration is loaded. func loadConfigs() (config.Group, error) { // -conf flag if conf != "" { diff --git a/server/server.go b/server/server.go index 8e93c294..24aa92eb 100644 --- a/server/server.go +++ b/server/server.go @@ -86,7 +86,7 @@ func (s *Server) Serve() error { go func(vh virtualHost) { // Wait for signal interrupt := make(chan os.Signal, 1) - signal.Notify(interrupt, os.Interrupt, os.Kill) + signal.Notify(interrupt, os.Interrupt, os.Kill) // TODO: syscall.SIGQUIT? (Ctrl+\, Unix-only) <-interrupt // Run callbacks From e0fdddc73fc20f57b5606fddc7cac28cbbf88c7c Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Thu, 15 Oct 2015 00:07:26 -0600 Subject: [PATCH 04/73] Don't share sync.Once with all directives If each server block had only one sync.Once then all directives would refer to it and only the first directive would be able to use it! So this commit changes it to a map of sync.Once instances, keyed by directive. So by creating a new map for every server block, each directive in that block can get its own sync.Once which is exactly what is needed. They won't step on each other this way. --- config/config.go | 21 +++++++++++++++++++-- config/setup/controller.go | 7 ++++++- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/config/config.go b/config/config.go index 93f5146b..d413bde7 100644 --- a/config/config.go +++ b/config/config.go @@ -45,7 +45,7 @@ func Load(filename string, input io.Reader) (Group, error) { // Iterate each server block and make a config for each one, // executing the directives that were parsed. for _, sb := range serverBlocks { - var once sync.Once + onces := makeOnces() for _, addr := range sb.Addresses { config := server.Config{ @@ -68,7 +68,7 @@ func Load(filename string, input io.Reader) (Group, error) { controller := &setup.Controller{ Config: &config, Dispenser: parse.NewDispenserTokens(filename, tokens), - OncePerServerBlock: func(f func()) { once.Do(f) }, + OncePerServerBlock: func(f func()) { onces[dir.name].Do(f) }, } midware, err := dir.setup(controller) @@ -96,6 +96,23 @@ func Load(filename string, input io.Reader) (Group, error) { return arrangeBindings(configs) } +// makeOnces makes a map of directive name to sync.Once +// instance. This is intended to be called once per server +// block when setting up configs so that Setup functions +// for each directive can perform a task just once per +// server block, even if there are multiple hosts on the block. +// +// We need one Once per directive, otherwise the first +// directive to use it would exclude other directives from +// using it at all, which would be a bug. +func makeOnces() map[string]*sync.Once { + onces := make(map[string]*sync.Once) + for _, dir := range directiveOrder { + onces[dir.name] = new(sync.Once) + } + return onces +} + // arrangeBindings groups configurations by their bind address. For example, // a server that should listen on localhost and another on 127.0.0.1 will // be grouped into the same address: 127.0.0.1. It will return an error diff --git a/config/setup/controller.go b/config/setup/controller.go index 2974f2b5..357365e2 100644 --- a/config/setup/controller.go +++ b/config/setup/controller.go @@ -11,10 +11,15 @@ import ( ) // Controller is given to the setup function of middlewares which -// gives them access to be able to read tokens and set config. +// gives them access to be able to read tokens and set config. Each +// virtualhost gets their own server config and dispenser. type Controller struct { *server.Config parse.Dispenser + + // OncePerServerBlock is a function that executes f + // exactly once per server block, no matter how many + // hosts are associated with it. OncePerServerBlock func(f func()) } From 35e309cf875d3bff163dcae281008d2707c25bdb Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Thu, 15 Oct 2015 00:11:26 -0600 Subject: [PATCH 05/73] First use of OncePerServerBlock in a Setup function startup and shutdown commands should only be executed once per appearance in the Caddyfile (naturally meaning once per server block). Notice that we support multiple occurrences of startup and shutdown in the same server block by building the callback array incrementally as we parse the Caddyfile, then we append all the callbacks all at once. Quite literally, the OncePerServerBlock function executes only once per server block! --- config/setup/startupshutdown.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/config/setup/startupshutdown.go b/config/setup/startupshutdown.go index befadd42..ddcd1e64 100644 --- a/config/setup/startupshutdown.go +++ b/config/setup/startupshutdown.go @@ -20,6 +20,8 @@ func Shutdown(c *Controller) (middleware.Middleware, error) { // using c to parse the line. It appends the callback function // to the list of callback functions passed in by reference. func registerCallback(c *Controller, list *[]func() error) error { + var funcs []func() error + for c.Next() { args := c.RemainingArgs() if len(args) == 0 { @@ -50,8 +52,12 @@ func registerCallback(c *Controller, list *[]func() error) error { return cmd.Run() } - *list = append(*list, fn) + funcs = append(funcs, fn) } + c.OncePerServerBlock(func() { + *list = append(*list, funcs...) + }) + return nil } From 691204ceed9fd2f3894f304adcaf3a80b511bb91 Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Thu, 15 Oct 2015 11:38:17 -0600 Subject: [PATCH 06/73] OncePerServerBlock may now return an error --- config/config.go | 12 +++++++++--- config/setup/controller.go | 7 +++++-- config/setup/startupshutdown.go | 5 ++--- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/config/config.go b/config/config.go index d413bde7..b47acf8e 100644 --- a/config/config.go +++ b/config/config.go @@ -66,9 +66,15 @@ func Load(filename string, input io.Reader) (Group, error) { // server config and the dispenser containing only // this directive's tokens. controller := &setup.Controller{ - Config: &config, - Dispenser: parse.NewDispenserTokens(filename, tokens), - OncePerServerBlock: func(f func()) { onces[dir.name].Do(f) }, + Config: &config, + Dispenser: parse.NewDispenserTokens(filename, tokens), + OncePerServerBlock: func(f func() error) error { + var err error + onces[dir.name].Do(func() { + err = f() + }) + return err + }, } midware, err := dir.setup(controller) diff --git a/config/setup/controller.go b/config/setup/controller.go index 3b78dbcd..d0b0ee89 100644 --- a/config/setup/controller.go +++ b/config/setup/controller.go @@ -19,8 +19,11 @@ type Controller struct { // OncePerServerBlock is a function that executes f // exactly once per server block, no matter how many - // hosts are associated with it. - OncePerServerBlock func(f func()) + // hosts are associated with it. If it is the first + // time, the function f is executed immediately + // (not deferred) and may return an error which is + // returned by OncePerServerBlock. + OncePerServerBlock func(f func() error) error } // NewTestController creates a new *Controller for diff --git a/config/setup/startupshutdown.go b/config/setup/startupshutdown.go index ddcd1e64..e4d87305 100644 --- a/config/setup/startupshutdown.go +++ b/config/setup/startupshutdown.go @@ -55,9 +55,8 @@ func registerCallback(c *Controller, list *[]func() error) error { funcs = append(funcs, fn) } - c.OncePerServerBlock(func() { + return c.OncePerServerBlock(func() error { *list = append(*list, funcs...) + return nil }) - - return nil } From 2236780190002adbdec61d41ff7e33ad4a09b76e Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Thu, 15 Oct 2015 23:34:54 -0600 Subject: [PATCH 07/73] Add ServerBlockIndex and ServerBlockHosts to Controller This way, Setup functions have access to the list of hosts that share the server block, and also, if needed for some reason, the index of the server block in the input --- config/config.go | 4 +++- config/parse/parsing.go | 12 ++++++++++++ config/setup/controller.go | 9 +++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/config/config.go b/config/config.go index b47acf8e..d013d5a5 100644 --- a/config/config.go +++ b/config/config.go @@ -44,7 +44,7 @@ func Load(filename string, input io.Reader) (Group, error) { // Each server block represents similar hosts/addresses. // Iterate each server block and make a config for each one, // executing the directives that were parsed. - for _, sb := range serverBlocks { + for i, sb := range serverBlocks { onces := makeOnces() for _, addr := range sb.Addresses { @@ -75,6 +75,8 @@ func Load(filename string, input io.Reader) (Group, error) { }) return err }, + ServerBlockIndex: i, + ServerBlockHosts: sb.HostList(), } midware, err := dir.setup(controller) diff --git a/config/parse/parsing.go b/config/parse/parsing.go index 6ec04dce..59455391 100644 --- a/config/parse/parsing.go +++ b/config/parse/parsing.go @@ -312,3 +312,15 @@ type ( Host, Port string } ) + +// HostList converts the list of addresses (hosts) +// that are associated with this server block into +// a slice of strings. Each string is a host:port +// combination. +func (sb serverBlock) HostList() []string { + sbHosts := make([]string, len(sb.Addresses)) + for j, addr := range sb.Addresses { + sbHosts[j] = net.JoinHostPort(addr.Host, addr.Port) + } + return sbHosts +} diff --git a/config/setup/controller.go b/config/setup/controller.go index d0b0ee89..eb9b90cf 100644 --- a/config/setup/controller.go +++ b/config/setup/controller.go @@ -24,6 +24,15 @@ type Controller struct { // (not deferred) and may return an error which is // returned by OncePerServerBlock. OncePerServerBlock func(f func() error) error + + // ServerBlockIndex is the 0-based index of the + // server block as it appeared in the input. + ServerBlockIndex int + + // ServerBlockHosts is a list of hosts that are + // associated with this server block. All these + // hosts, consequently, share the same tokens. + ServerBlockHosts []string } // NewTestController creates a new *Controller for From 4e92c7125951f7f9487914ccb9febe94b515e62a Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Fri, 16 Oct 2015 11:38:56 -0600 Subject: [PATCH 08/73] LE flags, modified tis directive, moved LE stuff to own file --- app/app.go | 24 ++++++++++++++++++++ config/config.go | 52 ++++++++++++++++--------------------------- config/letsencrypt.go | 35 +++++++++++++++++++++++++++++ config/setup/tls.go | 19 +++++++++------- main.go | 2 ++ server/config.go | 5 ++--- 6 files changed, 93 insertions(+), 44 deletions(-) create mode 100644 config/letsencrypt.go diff --git a/app/app.go b/app/app.go index b006d294..e8cd16bd 100644 --- a/app/app.go +++ b/app/app.go @@ -7,6 +7,8 @@ package app import ( "errors" + "os" + "path/filepath" "runtime" "strconv" "strings" @@ -74,3 +76,25 @@ func SetCPU(cpu string) error { runtime.GOMAXPROCS(numCPU) return nil } + +// DataFolder returns the path to the folder +// where the application may store data. This +// currently resolves to ~/.caddy +func DataFolder() string { + return filepath.Join(userHomeDir(), ".caddy") +} + +// userHomeDir returns the user's home directory according to +// environment variables. +// +// Credit: http://stackoverflow.com/a/7922977/1048862 +func userHomeDir() string { + if runtime.GOOS == "windows" { + home := os.Getenv("HOMEDRIVE") + os.Getenv("HOMEPATH") + if home == "" { + home = os.Getenv("USERPROFILE") + } + return home + } + return os.Getenv("HOME") +} diff --git a/config/config.go b/config/config.go index a763d3c2..ea92368e 100644 --- a/config/config.go +++ b/config/config.go @@ -1,8 +1,6 @@ package config import ( - "crypto/rand" - "crypto/rsa" "errors" "fmt" "io" @@ -77,25 +75,20 @@ func Load(filename string, input io.Reader) (Group, error) { // restore logging settings log.SetFlags(flags) - privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + // Initiate Let's Encrypt + leUser, err := NewLetsEncryptUser("example1@mail.com") if err != nil { - return Group{}, errors.New("Error Generating Key:" + err.Error()) + return Group{}, err } - for _, cfg := range configs { - // TODO: && hostname does not resolve to localhost (?) && TLS is not force-disabled - if !cfg.TLS.Enabled { - // Initiate Let's Encrypt - user := LetsEncryptUser{ - Email: "example@mail.com", - Key: privateKey, - } - client := acme.NewClient("http://192.168.99.100:4000", &user, 2048, "5001") + // TODO: && !IsLoopback() + if !cfg.TLS.Enabled && cfg.Port != "http" { + client := acme.NewClient("http://192.168.99.100:4000", &leUser, 2048, "5001") reg, err := client.Register() if err != nil { return Group{}, errors.New("Error Registering: " + err.Error()) } - user.Registration = reg + leUser.Registration = reg err = client.AgreeToTos() if err != nil { @@ -106,8 +99,6 @@ func Load(filename string, input io.Reader) (Group, error) { if err != nil { return Group{}, errors.New("Error Obtaining Certs: " + err.Error()) } - - fmt.Printf("%#v\n", certs) } } @@ -115,22 +106,6 @@ func Load(filename string, input io.Reader) (Group, error) { return arrangeBindings(configs) } -type LetsEncryptUser struct { - Email string - Registration *acme.RegistrationResource - Key *rsa.PrivateKey -} - -func (u LetsEncryptUser) GetEmail() string { - return u.Email -} -func (u LetsEncryptUser) GetRegistration() *acme.RegistrationResource { - return u.Registration -} -func (u LetsEncryptUser) GetPrivateKey() *rsa.PrivateKey { - return u.Key -} - // serverBlockToConfig makes a config for the server block // by executing the tokens that were parsed. The returned // config is shared among all hosts/addresses for the server @@ -303,11 +278,22 @@ func Default() (Group, error) { return arrangeBindings([]server.Config{NewDefault()}) } -// These three defaults are configurable through the command line +// These defaults are configurable through the command line var ( + // Site root Root = DefaultRoot + + // Site host Host = DefaultHost + + // Site port Port = DefaultPort + + // Let's Encrypt account email + LetsEncryptEmail string + + // Agreement to Let's Encrypt terms + LetsEncryptAgree bool ) type Group map[*net.TCPAddr][]server.Config diff --git a/config/letsencrypt.go b/config/letsencrypt.go new file mode 100644 index 00000000..53aac694 --- /dev/null +++ b/config/letsencrypt.go @@ -0,0 +1,35 @@ +package config + +import ( + "crypto/rand" + "crypto/rsa" + "errors" + + "github.com/xenolf/lego/acme" +) + +func NewLetsEncryptUser(email string) (LetsEncryptUser, error) { + user := LetsEncryptUser{Email: email} + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return user, errors.New("error generating private key: " + err.Error()) + } + user.Key = privateKey + return user, nil +} + +type LetsEncryptUser struct { + Email string + Registration *acme.RegistrationResource + Key *rsa.PrivateKey +} + +func (u LetsEncryptUser) GetEmail() string { + return u.Email +} +func (u LetsEncryptUser) GetRegistration() *acme.RegistrationResource { + return u.Registration +} +func (u LetsEncryptUser) GetPrivateKey() *rsa.PrivateKey { + return u.Key +} diff --git a/config/setup/tls.go b/config/setup/tls.go index 431409f4..8e0b7f03 100644 --- a/config/setup/tls.go +++ b/config/setup/tls.go @@ -8,18 +8,21 @@ import ( ) func TLS(c *Controller) (middleware.Middleware, error) { - c.TLS.Enabled = true + if c.Port != "http" { + c.TLS.Enabled = true + } for c.Next() { - if !c.NextArg() { + args := c.RemainingArgs() + switch len(args) { + case 1: + c.TLS.LetsEncryptEmail = args[0] + case 2: + c.TLS.Certificate = args[0] + c.TLS.Key = args[1] + default: return nil, c.ArgErr() } - c.TLS.Certificate = c.Val() - - if !c.NextArg() { - return nil, c.ArgErr() - } - c.TLS.Key = c.Val() // Optional block for c.NextBlock() { diff --git a/main.go b/main.go index c1c0d143..7187567d 100644 --- a/main.go +++ b/main.go @@ -33,6 +33,8 @@ func init() { flag.StringVar(&config.Host, "host", config.DefaultHost, "Default host") flag.StringVar(&config.Port, "port", config.DefaultPort, "Default port") flag.BoolVar(&version, "version", false, "Show version") + flag.BoolVar(&config.LetsEncryptAgree, "agree", false, "Agree to Let's Encrypt Subscriber Agreement") + flag.StringVar(&config.LetsEncryptEmail, "email", "", "Email address to use for Let's Encrypt account") } func main() { diff --git a/server/config.go b/server/config.go index 4bcea8b2..dedd7ba3 100644 --- a/server/config.go +++ b/server/config.go @@ -50,13 +50,12 @@ func (c Config) Address() string { return net.JoinHostPort(c.Host, c.Port) } -// TLSConfig describes how TLS should be configured and used, -// if at all. A certificate and key are both required. -// The rest is optional. +// TLSConfig describes how TLS should be configured and used. type TLSConfig struct { Enabled bool Certificate string Key string + LetsEncryptEmail string Ciphers []uint16 ProtocolMinVersion uint16 ProtocolMaxVersion uint16 From 10619f06b47908617b0f8122e7b07d28be0a377d Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Fri, 16 Oct 2015 11:47:13 -0600 Subject: [PATCH 09/73] core: Disable TLS for sites where http is explicitly defined (fix) --- config/setup/tls.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/config/setup/tls.go b/config/setup/tls.go index 431409f4..e9d3db7e 100644 --- a/config/setup/tls.go +++ b/config/setup/tls.go @@ -2,6 +2,7 @@ package setup import ( "crypto/tls" + "log" "strings" "github.com/mholt/caddy/middleware" @@ -10,6 +11,12 @@ import ( func TLS(c *Controller) (middleware.Middleware, error) { c.TLS.Enabled = true + if c.Port == "http" { + c.TLS.Enabled = false + log.Printf("Warning: TLS disabled for %s://%s. To force TLS over the plaintext HTTP port, "+ + "specify port 80 explicitly (https://%s:80).", c.Port, c.Host, c.Host) + } + for c.Next() { if !c.NextArg() { return nil, c.ArgErr() From a0c8428f8cbc3cfb024d8e0289fe0b1f70422445 Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Fri, 16 Oct 2015 23:30:00 -0600 Subject: [PATCH 10/73] Can issue and use SSL certs and serve sites Code is a huge mess; much cleanup to follow. --- config/config.go | 31 +----- config/letsencrypt.go | 241 +++++++++++++++++++++++++++++++++++++++++- config/setup/tls.go | 5 +- 3 files changed, 241 insertions(+), 36 deletions(-) diff --git a/config/config.go b/config/config.go index fee6b34e..fc5fe182 100644 --- a/config/config.go +++ b/config/config.go @@ -1,7 +1,6 @@ package config import ( - "errors" "fmt" "io" "log" @@ -13,7 +12,6 @@ import ( "github.com/mholt/caddy/config/setup" "github.com/mholt/caddy/middleware" "github.com/mholt/caddy/server" - "github.com/xenolf/lego/acme" ) const ( @@ -103,34 +101,13 @@ func Load(filename string, input io.Reader) (Group, error) { // restore logging settings log.SetFlags(flags) - // Initiate Let's Encrypt - leUser, err := NewLetsEncryptUser("example1@mail.com") + // secure all the things + err = initiateLetsEncrypt(configs) if err != nil { - return Group{}, err - } - for _, cfg := range configs { - // TODO: && !IsLoopback() - if !cfg.TLS.Enabled && cfg.Port != "http" { - client := acme.NewClient("http://192.168.99.100:4000", &leUser, 2048, "5001") - reg, err := client.Register() - if err != nil { - return Group{}, errors.New("Error Registering: " + err.Error()) - } - leUser.Registration = reg - - err = client.AgreeToTos() - if err != nil { - return Group{}, errors.New("Error Agreeing to ToS: " + err.Error()) - } - - certs, err := client.ObtainCertificates([]string{"caddy.dev"}) - if err != nil { - return Group{}, errors.New("Error Obtaining Certs: " + err.Error()) - } - } + return nil, err } - // Group by address/virtualhosts + // group by address/virtualhosts return arrangeBindings(configs) } diff --git a/config/letsencrypt.go b/config/letsencrypt.go index 53aac694..e7e2a46f 100644 --- a/config/letsencrypt.go +++ b/config/letsencrypt.go @@ -1,27 +1,214 @@ package config import ( + "bufio" "crypto/rand" "crypto/rsa" + "crypto/x509" + "encoding/json" + "encoding/pem" "errors" + "fmt" + "io/ioutil" + "os" + "path/filepath" + "strings" + "github.com/mholt/caddy/app" + "github.com/mholt/caddy/server" "github.com/xenolf/lego/acme" ) -func NewLetsEncryptUser(email string) (LetsEncryptUser, error) { +const rsaKeySize = 2048 + +// initiateLetsEncrypt sets up TLS ... +func initiateLetsEncrypt(configs []server.Config) error { + // fill map of email address to server configs that use that email address for TLS. + // this will help us reduce roundtrips when getting the certs. + initMap := make(map[string][]*server.Config) + for i := 0; i < len(configs); i++ { + if configs[i].TLS.Certificate == "" && configs[i].TLS.Key == "" && configs[i].Port != "http" { // TODO: && !cfg.Host.IsLoopback() + leEmail := getEmail(configs[i]) + if leEmail == "" { + return errors.New("cannot serve HTTPS without email address OR certificate and key") + } + initMap[leEmail] = append(initMap[leEmail], &configs[i]) + } + } + + for leEmail, serverConfigs := range initMap { + leUser, err := getLetsEncryptUser(leEmail) + if err != nil { + return err + } + + client := acme.NewClient("http://192.168.99.100:4000", &leUser, rsaKeySize, "5001") + + if leUser.Registration == nil { + reg, err := client.Register() + if err != nil { + return errors.New("registration error: " + err.Error()) + } + leUser.Registration = reg + + // TODO: we can just do the agreement once, when registering, right? + err = client.AgreeToTos() + if err != nil { + saveLetsEncryptUser(leUser) // TODO: Might as well try, right? Error check? + return errors.New("error agreeing to terms: " + err.Error()) + } + + err = saveLetsEncryptUser(leUser) + if err != nil { + return errors.New("could not save user: " + err.Error()) + } + } + + // collect all the hostnames + var hosts []string + for _, cfg := range serverConfigs { + hosts = append(hosts, cfg.Host) + } + + // showtime: let's get free, trusted SSL certificates! yee-haw! + certificates, err := client.ObtainCertificates(hosts) + if err != nil { + return errors.New("error obtaining certs: " + err.Error()) + } + + // ... that's it. pain gone. save the certs, keys, and update server configs. + for _, cert := range certificates { + certFolder := filepath.Join(app.DataFolder(), "letsencrypt", "sites", cert.Domain) + os.MkdirAll(certFolder, 0700) + // Save cert + err = saveCertificate(cert.Certificate, filepath.Join(certFolder, cert.Domain+".crt")) + //err = ioutil.WriteFile(filepath.Join(certFolder, cert.Domain+".crt"), cert.Certificate, 0600) + if err != nil { + return err + } + + // Save private key + //savePrivateKey(cert.PrivateKey, filepath.Join(certFolder, cert.Domain+".key")) + err = ioutil.WriteFile(filepath.Join(certFolder, cert.Domain+".key"), cert.PrivateKey, 0600) + if err != nil { + return err + } + + // Save cert metadata + jsonBytes, err := json.MarshalIndent(&CertificateMeta{URL: cert.CertURL, Domain: cert.Domain}, "", "\t") + if err != nil { + return err + } + err = ioutil.WriteFile(filepath.Join(certFolder, cert.Domain+".json"), jsonBytes, 0600) + if err != nil { + return err + } + } + + // it all comes down to this: filling in the file path of a valid certificate automatically + for _, cfg := range serverConfigs { + cfg.TLS.Certificate = filepath.Join(app.DataFolder(), "letsencrypt", "sites", cfg.Host, cfg.Host+".crt") + cfg.TLS.Key = filepath.Join(app.DataFolder(), "letsencrypt", "sites", cfg.Host, cfg.Host+".key") + } + } + + return nil +} + +func getEmail(cfg server.Config) string { + leEmail := cfg.TLS.LetsEncryptEmail + if leEmail == "" { + leEmail = LetsEncryptEmail + } + if leEmail == "" { + // TODO: get most recent email from ~/.caddy/users file + } + if leEmail == "" { + reader := bufio.NewReader(os.Stdin) + fmt.Print("Email address: ") + var err error + leEmail, err = reader.ReadString('\n') + if err != nil { + return "" + } + LetsEncryptEmail = leEmail + } + return strings.TrimSpace(leEmail) +} + +func saveLetsEncryptUser(user LetsEncryptUser) error { + // make user account folder + userFolder := filepath.Join(app.DataFolder(), "letsencrypt", "users", user.Email) + err := os.MkdirAll(userFolder, 0700) + if err != nil { + return err + } + + // save private key file + user.KeyFile = filepath.Join(userFolder, emailUsername(user.Email)+".key") + err = savePrivateKey(user.key, user.KeyFile) + if err != nil { + return err + } + + // save registration file + jsonBytes, err := json.MarshalIndent(&user, "", "\t") + if err != nil { + return err + } + + return ioutil.WriteFile(filepath.Join(userFolder, "registration.json"), jsonBytes, 0600) +} + +func getLetsEncryptUser(email string) (LetsEncryptUser, error) { + var user LetsEncryptUser + + userFolder := filepath.Join(app.DataFolder(), "letsencrypt", "users", email) + regFile, err := os.Open(filepath.Join(userFolder, "registration.json")) + if err != nil { + if os.IsNotExist(err) { + // create a new user + return newLetsEncryptUser(email) + } + return user, err + } + + err = json.NewDecoder(regFile).Decode(&user) + if err != nil { + return user, err + } + + user.key, err = loadPrivateKey(user.KeyFile) + if err != nil { + return user, err + } + + return user, nil +} + +func newLetsEncryptUser(email string) (LetsEncryptUser, error) { user := LetsEncryptUser{Email: email} - privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + privateKey, err := rsa.GenerateKey(rand.Reader, rsaKeySize) if err != nil { return user, errors.New("error generating private key: " + err.Error()) } - user.Key = privateKey + user.key = privateKey return user, nil } +func emailUsername(email string) string { + at := strings.Index(email, "@") + if at == -1 { + return email + } + return email[:at] +} + type LetsEncryptUser struct { Email string Registration *acme.RegistrationResource - Key *rsa.PrivateKey + KeyFile string + key *rsa.PrivateKey } func (u LetsEncryptUser) GetEmail() string { @@ -31,5 +218,49 @@ func (u LetsEncryptUser) GetRegistration() *acme.RegistrationResource { return u.Registration } func (u LetsEncryptUser) GetPrivateKey() *rsa.PrivateKey { - return u.Key + return u.key +} + +// savePrivateKey saves an RSA private key to file. +// +// Borrowed from Sebastian Erhart +// https://github.com/xenolf/lego/blob/34910bd541315993224af1f04f9b2877513e5477/crypto.go +func savePrivateKey(key *rsa.PrivateKey, file string) error { + pemKey := pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)} + keyOut, err := os.Create(file) + if err != nil { + return err + } + pem.Encode(keyOut, &pemKey) + keyOut.Close() + return nil +} + +// TODO: Check file permission +func saveCertificate(certBytes []byte, file string) error { + pemCert := pem.Block{Type: "CERTIFICATE", Bytes: certBytes} + certOut, err := os.Create(file) + if err != nil { + return err + } + pem.Encode(certOut, &pemCert) + certOut.Close() + return nil +} + +// loadPrivateKey loads an RSA private key from filename. +// +// Borrowed from Sebastian Erhart +// https://github.com/xenolf/lego/blob/34910bd541315993224af1f04f9b2877513e5477/crypto.go +func loadPrivateKey(file string) (*rsa.PrivateKey, error) { + keyBytes, err := ioutil.ReadFile(file) + if err != nil { + return nil, err + } + keyBlock, _ := pem.Decode(keyBytes) + return x509.ParsePKCS1PrivateKey(keyBlock.Bytes) +} + +type CertificateMeta struct { + Domain, URL string } diff --git a/config/setup/tls.go b/config/setup/tls.go index 785eeb5c..1ddef0b3 100644 --- a/config/setup/tls.go +++ b/config/setup/tls.go @@ -11,10 +11,7 @@ import ( func TLS(c *Controller) (middleware.Middleware, error) { if c.Port != "http" { c.TLS.Enabled = true - } - - if c.Port == "http" { - c.TLS.Enabled = false + } else { log.Printf("Warning: TLS disabled for %s://%s. To force TLS over the plaintext HTTP port, "+ "specify port 80 explicitly (https://%s:80).", c.Port, c.Host, c.Host) } From 972760325084591e984e15057bc717ce516eba7c Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Sat, 17 Oct 2015 00:01:32 -0600 Subject: [PATCH 11/73] Try to use most recent user email if not provided Also more comments and starting to clean up code --- config/letsencrypt.go | 60 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 49 insertions(+), 11 deletions(-) diff --git a/config/letsencrypt.go b/config/letsencrypt.go index e7e2a46f..fd848172 100644 --- a/config/letsencrypt.go +++ b/config/letsencrypt.go @@ -19,11 +19,24 @@ import ( "github.com/xenolf/lego/acme" ) -const rsaKeySize = 2048 +// Some essential values related to the Let's Encrypt process +const ( + // Size of RSA keys in bits + rsaKeySize = 2048 -// initiateLetsEncrypt sets up TLS ... + // The base URL to the Let's Encrypt CA + caURL = "http://192.168.99.100:4000" + + // The port to expose to the CA server for Simple HTTP Challenge + exposePort = "5001" +) + +// initiateLetsEncrypt sets up TLS for each server config +// in configs as needed. It only skips the config if the +// cert and key are already specified or if plaintext http +// is explicitly specified as the port. func initiateLetsEncrypt(configs []server.Config) error { - // fill map of email address to server configs that use that email address for TLS. + // populate map of email address to server configs that use that email address for TLS. // this will help us reduce roundtrips when getting the certs. initMap := make(map[string][]*server.Config) for i := 0; i < len(configs); i++ { @@ -36,14 +49,20 @@ func initiateLetsEncrypt(configs []server.Config) error { } } + // Loop through each email address and obtain certs; we can obtain more + // than one certificate per email address, and still save them individually. for leEmail, serverConfigs := range initMap { + // Look up or create the LE user account leUser, err := getLetsEncryptUser(leEmail) if err != nil { return err } - client := acme.NewClient("http://192.168.99.100:4000", &leUser, rsaKeySize, "5001") + // The client facilitates our communication with the CA server. + client := acme.NewClient(caURL, &leUser, rsaKeySize, exposePort) + // 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 err != nil { @@ -64,31 +83,30 @@ func initiateLetsEncrypt(configs []server.Config) error { } } - // collect all the hostnames + // collect all the hostnames into one slice var hosts []string for _, cfg := range serverConfigs { hosts = append(hosts, cfg.Host) } - // showtime: let's get free, trusted SSL certificates! yee-haw! + // showtime: let's get free, trusted SSL certificates! yeah! certificates, err := client.ObtainCertificates(hosts) if err != nil { return errors.New("error obtaining certs: " + err.Error()) } - // ... that's it. pain gone. save the certs, keys, and update server configs. + // ... that's it. save the certs, keys, and update server configs. for _, cert := range certificates { certFolder := filepath.Join(app.DataFolder(), "letsencrypt", "sites", cert.Domain) os.MkdirAll(certFolder, 0700) + // Save cert err = saveCertificate(cert.Certificate, filepath.Join(certFolder, cert.Domain+".crt")) - //err = ioutil.WriteFile(filepath.Join(certFolder, cert.Domain+".crt"), cert.Certificate, 0600) if err != nil { return err } // Save private key - //savePrivateKey(cert.PrivateKey, filepath.Join(certFolder, cert.Domain+".key")) err = ioutil.WriteFile(filepath.Join(certFolder, cert.Domain+".key"), cert.PrivateKey, 0600) if err != nil { return err @@ -115,17 +133,37 @@ func initiateLetsEncrypt(configs []server.Config) error { return nil } +// getEmail does everything it can to obtain an email +// address from the user to use for TLS for cfg. If it +// cannot get an email address, it returns empty string. func getEmail(cfg server.Config) string { + // First try the tls directive from the Caddyfile leEmail := cfg.TLS.LetsEncryptEmail if leEmail == "" { + // Then try memory (command line flag or typed by user previously) leEmail = LetsEncryptEmail } if leEmail == "" { - // TODO: get most recent email from ~/.caddy/users file + // Then try to get most recent user email ~/.caddy/users file + // TODO: Probably better to open the user's json file and read the email out of there... + userDirs, err := ioutil.ReadDir(filepath.Join(app.DataFolder(), "letsencrypt", "users")) + if err == nil { + var mostRecent os.FileInfo + for _, dir := range userDirs { + if !dir.IsDir() { + continue + } + if mostRecent == nil || dir.ModTime().After(mostRecent.ModTime()) { + mostRecent = dir + } + } + leEmail = mostRecent.Name() + } } if leEmail == "" { + // Alas, we must bother the user and ask for an email address reader := bufio.NewReader(os.Stdin) - fmt.Print("Email address: ") + fmt.Print("Email address: ") // TODO: More explanation probably, and show ToS? var err error leEmail, err = reader.ReadString('\n') if err != nil { From df194d567f393a1ad6efc95108e15ccefcc6155a Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Sat, 17 Oct 2015 09:06:05 -0600 Subject: [PATCH 12/73] Don't forget to set port to "https" and indicate TLS enabled --- config/letsencrypt.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/config/letsencrypt.go b/config/letsencrypt.go index fd848172..de6e29ba 100644 --- a/config/letsencrypt.go +++ b/config/letsencrypt.go @@ -127,6 +127,8 @@ func initiateLetsEncrypt(configs []server.Config) error { for _, cfg := range serverConfigs { cfg.TLS.Certificate = filepath.Join(app.DataFolder(), "letsencrypt", "sites", cfg.Host, cfg.Host+".crt") cfg.TLS.Key = filepath.Join(app.DataFolder(), "letsencrypt", "sites", cfg.Host, cfg.Host+".key") + cfg.TLS.Enabled = true + cfg.Port = "https" } } From 506630200bcbc733bb883530cd8a476283d38f41 Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Sat, 17 Oct 2015 09:36:25 -0600 Subject: [PATCH 13/73] Redirect HTTP requests to HTTPS by default --- config/config.go | 2 +- config/letsencrypt.go | 69 +++++++++++++++++++++++++++++++++++-------- 2 files changed, 58 insertions(+), 13 deletions(-) diff --git a/config/config.go b/config/config.go index fc5fe182..ea955682 100644 --- a/config/config.go +++ b/config/config.go @@ -102,7 +102,7 @@ func Load(filename string, input io.Reader) (Group, error) { log.SetFlags(flags) // secure all the things - err = initiateLetsEncrypt(configs) + configs, err = initiateLetsEncrypt(configs) if err != nil { return nil, err } diff --git a/config/letsencrypt.go b/config/letsencrypt.go index de6e29ba..0fa5b0c6 100644 --- a/config/letsencrypt.go +++ b/config/letsencrypt.go @@ -1,5 +1,8 @@ package config +// TODO: This code is a mess but I'm cleaning it up locally and +// refactoring a bunch. It will have tests, too. Don't worry. :) + import ( "bufio" "crypto/rand" @@ -10,11 +13,14 @@ import ( "errors" "fmt" "io/ioutil" + "net/http" "os" "path/filepath" "strings" "github.com/mholt/caddy/app" + "github.com/mholt/caddy/middleware" + "github.com/mholt/caddy/middleware/redirect" "github.com/mholt/caddy/server" "github.com/xenolf/lego/acme" ) @@ -35,7 +41,7 @@ const ( // in configs as needed. It only skips the config if the // cert and key are already specified or if plaintext http // is explicitly specified as the port. -func initiateLetsEncrypt(configs []server.Config) error { +func initiateLetsEncrypt(configs []server.Config) ([]server.Config, error) { // populate map of email address to server configs that use that email address for TLS. // this will help us reduce roundtrips when getting the certs. initMap := make(map[string][]*server.Config) @@ -43,7 +49,7 @@ func initiateLetsEncrypt(configs []server.Config) error { if configs[i].TLS.Certificate == "" && configs[i].TLS.Key == "" && configs[i].Port != "http" { // TODO: && !cfg.Host.IsLoopback() leEmail := getEmail(configs[i]) if leEmail == "" { - return errors.New("cannot serve HTTPS without email address OR certificate and key") + return configs, errors.New("cannot serve HTTPS without email address OR certificate and key") } initMap[leEmail] = append(initMap[leEmail], &configs[i]) } @@ -55,7 +61,7 @@ func initiateLetsEncrypt(configs []server.Config) error { // Look up or create the LE user account leUser, err := getLetsEncryptUser(leEmail) if err != nil { - return err + return configs, err } // The client facilitates our communication with the CA server. @@ -66,7 +72,7 @@ func initiateLetsEncrypt(configs []server.Config) error { if leUser.Registration == nil { reg, err := client.Register() if err != nil { - return errors.New("registration error: " + err.Error()) + return configs, errors.New("registration error: " + err.Error()) } leUser.Registration = reg @@ -74,12 +80,12 @@ func initiateLetsEncrypt(configs []server.Config) error { err = client.AgreeToTos() if err != nil { saveLetsEncryptUser(leUser) // TODO: Might as well try, right? Error check? - return errors.New("error agreeing to terms: " + err.Error()) + return configs, errors.New("error agreeing to terms: " + err.Error()) } err = saveLetsEncryptUser(leUser) if err != nil { - return errors.New("could not save user: " + err.Error()) + return configs, errors.New("could not save user: " + err.Error()) } } @@ -92,7 +98,7 @@ func initiateLetsEncrypt(configs []server.Config) error { // showtime: let's get free, trusted SSL certificates! yeah! certificates, err := client.ObtainCertificates(hosts) if err != nil { - return errors.New("error obtaining certs: " + err.Error()) + return configs, errors.New("error obtaining certs: " + err.Error()) } // ... that's it. save the certs, keys, and update server configs. @@ -103,23 +109,23 @@ func initiateLetsEncrypt(configs []server.Config) error { // Save cert err = saveCertificate(cert.Certificate, filepath.Join(certFolder, cert.Domain+".crt")) if err != nil { - return err + return configs, err } // Save private key err = ioutil.WriteFile(filepath.Join(certFolder, cert.Domain+".key"), cert.PrivateKey, 0600) if err != nil { - return err + return configs, err } // Save cert metadata jsonBytes, err := json.MarshalIndent(&CertificateMeta{URL: cert.CertURL, Domain: cert.Domain}, "", "\t") if err != nil { - return err + return configs, err } err = ioutil.WriteFile(filepath.Join(certFolder, cert.Domain+".json"), jsonBytes, 0600) if err != nil { - return err + return configs, err } } @@ -129,10 +135,49 @@ func initiateLetsEncrypt(configs []server.Config) error { cfg.TLS.Key = filepath.Join(app.DataFolder(), "letsencrypt", "sites", cfg.Host, cfg.Host+".key") cfg.TLS.Enabled = true cfg.Port = "https" + + // Is there a plaintext HTTP config for the same host? If not, make + // one and have it redirect all requests to this HTTPS host. + var plaintextHostFound bool + for _, otherCfg := range configs { + if cfg.Host == otherCfg.Host && otherCfg.Port == "http" { + plaintextHostFound = true + break + } + } + + if !plaintextHostFound { + // Make one that redirects to HTTPS for all requests + configs = append(configs, redirPlaintextHost(cfg)) + } } } - return nil + return configs, nil +} + +// redirPlaintextHost returns a new virtualhost configuration for a server +// that redirects the plaintext HTTP host of cfg to cfg, which is assumed +// to be the secure (HTTPS) host. +func redirPlaintextHost(cfg server.Config) server.Config { + redirMidware := func(next middleware.Handler) middleware.Handler { + return redirect.Redirect{Next: next, Rules: []redirect.Rule{ + { + FromScheme: "http", + FromPath: "/", + To: "https://" + cfg.Host + "{uri}", + Code: http.StatusMovedPermanently, + }, + }} + } + + return server.Config{ + Host: cfg.Host, + Port: "http", + Middleware: map[string][]middleware.Middleware{ + "/": []middleware.Middleware{redirMidware}, + }, + } } // getEmail does everything it can to obtain an email From 06913ab74fb0ae8c63abf4c0a361ce1e4ec80290 Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Sat, 17 Oct 2015 11:15:43 -0600 Subject: [PATCH 14/73] Oops (pass a pointer) --- config/letsencrypt.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/letsencrypt.go b/config/letsencrypt.go index 0fa5b0c6..63135171 100644 --- a/config/letsencrypt.go +++ b/config/letsencrypt.go @@ -148,7 +148,7 @@ func initiateLetsEncrypt(configs []server.Config) ([]server.Config, error) { if !plaintextHostFound { // Make one that redirects to HTTPS for all requests - configs = append(configs, redirPlaintextHost(cfg)) + configs = append(configs, redirPlaintextHost(*cfg)) } } } From 307c2ffe3ca1fa567049d7c2d191cc310ed00bfc Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Sat, 17 Oct 2015 11:19:56 -0600 Subject: [PATCH 15/73] Remove obsolete test --- config/setup/tls_test.go | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/config/setup/tls_test.go b/config/setup/tls_test.go index fe1ce655..fdea1e0c 100644 --- a/config/setup/tls_test.go +++ b/config/setup/tls_test.go @@ -70,14 +70,7 @@ func TestTLSParseIncompleteParams(t *testing.T) { _, err := TLS(c) if err == nil { - t.Errorf("Expected errors, but no error returned") - } - - c = NewTestController(`tls cert.key`) - - _, err = TLS(c) - if err == nil { - t.Errorf("Expected errors, but no error returned") + t.Errorf("Expected errors (first check), but no error returned") } } From fe7ad8ee056c47b1d52d890cdb70fa6ecba0a38b Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Sat, 17 Oct 2015 14:11:32 -0600 Subject: [PATCH 16/73] core: Controller has field to persist server state Also added ServerBlockHostIndex --- config/config.go | 21 ++++++++++++++++++--- config/setup/controller.go | 16 ++++++++++++++-- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/config/config.go b/config/config.go index d013d5a5..1e61cd9e 100644 --- a/config/config.go +++ b/config/config.go @@ -46,8 +46,9 @@ func Load(filename string, input io.Reader) (Group, error) { // executing the directives that were parsed. for i, sb := range serverBlocks { onces := makeOnces() + storages := makeStorages() - for _, addr := range sb.Addresses { + for j, addr := range sb.Addresses { config := server.Config{ Host: addr.Host, Port: addr.Port, @@ -75,8 +76,10 @@ func Load(filename string, input io.Reader) (Group, error) { }) return err }, - ServerBlockIndex: i, - ServerBlockHosts: sb.HostList(), + ServerBlockIndex: i, + ServerBlockHostIndex: j, + ServerBlockHosts: sb.HostList(), + ServerBlockStorage: storages[dir.name], } midware, err := dir.setup(controller) @@ -121,6 +124,18 @@ func makeOnces() map[string]*sync.Once { return onces } +// makeStorages makes a map of directive name to interface{} +// so that directives' setup functions can persist state +// between different hosts on the same server block during the +// setup phase. +func makeStorages() map[string]interface{} { + storages := make(map[string]interface{}) + for _, dir := range directiveOrder { + storages[dir.name] = nil + } + return storages +} + // arrangeBindings groups configurations by their bind address. For example, // a server that should listen on localhost and another on 127.0.0.1 will // be grouped into the same address: 127.0.0.1. It will return an error diff --git a/config/setup/controller.go b/config/setup/controller.go index eb9b90cf..04873082 100644 --- a/config/setup/controller.go +++ b/config/setup/controller.go @@ -29,17 +29,29 @@ type Controller struct { // server block as it appeared in the input. ServerBlockIndex int + // ServerBlockHostIndex is the 0-based index of this + // host as it appeared in the input at the head of the + // server block. + ServerBlockHostIndex int + // ServerBlockHosts is a list of hosts that are // associated with this server block. All these // hosts, consequently, share the same tokens. ServerBlockHosts []string + + // ServerBlockStorage is used by a directive's + // setup function to persist state between all + // the hosts on a server block. + ServerBlockStorage interface{} } // NewTestController creates a new *Controller for -// the input specified, with a filename of "Testfile" +// the input specified, with a filename of "Testfile". +// The Config is bare, consisting only of a Root of cwd. // // Used primarily for testing but needs to be exported so -// add-ons can use this as a convenience. +// add-ons can use this as a convenience. Does not initialize +// the server-block-related fields. func NewTestController(input string) *Controller { return &Controller{ Config: &server.Config{ From a3a826572f82c356b1dad6eeae024589896bf5a9 Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Sat, 17 Oct 2015 20:17:24 -0600 Subject: [PATCH 17/73] Refactor letsencrypt code into its own package --- config/config.go | 9 +- config/letsencrypt/crypto.go | 43 +++++ config/{ => letsencrypt}/letsencrypt.go | 204 ++++++------------------ config/letsencrypt/storage.go | 128 +++++++++++++++ config/letsencrypt/user.go | 97 +++++++++++ main.go | 5 +- 6 files changed, 318 insertions(+), 168 deletions(-) create mode 100644 config/letsencrypt/crypto.go rename config/{ => letsencrypt}/letsencrypt.go (54%) create mode 100644 config/letsencrypt/storage.go create mode 100644 config/letsencrypt/user.go diff --git a/config/config.go b/config/config.go index ea955682..11759351 100644 --- a/config/config.go +++ b/config/config.go @@ -8,6 +8,7 @@ import ( "sync" "github.com/mholt/caddy/app" + "github.com/mholt/caddy/config/letsencrypt" "github.com/mholt/caddy/config/parse" "github.com/mholt/caddy/config/setup" "github.com/mholt/caddy/middleware" @@ -102,7 +103,7 @@ func Load(filename string, input io.Reader) (Group, error) { log.SetFlags(flags) // secure all the things - configs, err = initiateLetsEncrypt(configs) + configs, err = letsencrypt.Activate(configs) if err != nil { return nil, err } @@ -272,12 +273,6 @@ var ( // Site port Port = DefaultPort - - // Let's Encrypt account email - LetsEncryptEmail string - - // Agreement to Let's Encrypt terms - LetsEncryptAgree bool ) // Group maps network addresses to their configurations. diff --git a/config/letsencrypt/crypto.go b/config/letsencrypt/crypto.go new file mode 100644 index 00000000..5c84b4e4 --- /dev/null +++ b/config/letsencrypt/crypto.go @@ -0,0 +1,43 @@ +package letsencrypt + +import ( + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "io/ioutil" + "os" +) + +// saveCertificate saves a DER-encoded (binary format) certificate +// to file. +func saveCertificate(certBytes []byte, file string) error { + pemCert := pem.Block{Type: "CERTIFICATE", Bytes: certBytes} + certOut, err := os.Create(file) + if err != nil { + return err + } + pem.Encode(certOut, &pemCert) + certOut.Close() + return nil +} + +// loadRSAPrivateKey loads a PEM-encoded RSA private key from file. +func loadRSAPrivateKey(file string) (*rsa.PrivateKey, error) { + keyBytes, err := ioutil.ReadFile(file) + if err != nil { + return nil, err + } + keyBlock, _ := pem.Decode(keyBytes) + return x509.ParsePKCS1PrivateKey(keyBlock.Bytes) +} + +// saveRSAPrivateKey saves a PEM-encoded RSA private key to file. +func saveRSAPrivateKey(key *rsa.PrivateKey, file string) error { + pemKey := pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)} + keyOut, err := os.Create(file) + if err != nil { + return err + } + defer keyOut.Close() + return pem.Encode(keyOut, &pemKey) +} diff --git a/config/letsencrypt.go b/config/letsencrypt/letsencrypt.go similarity index 54% rename from config/letsencrypt.go rename to config/letsencrypt/letsencrypt.go index 63135171..2782fc0d 100644 --- a/config/letsencrypt.go +++ b/config/letsencrypt/letsencrypt.go @@ -1,47 +1,26 @@ -package config - -// TODO: This code is a mess but I'm cleaning it up locally and -// refactoring a bunch. It will have tests, too. Don't worry. :) +package letsencrypt import ( "bufio" - "crypto/rand" - "crypto/rsa" - "crypto/x509" "encoding/json" - "encoding/pem" "errors" "fmt" "io/ioutil" "net/http" "os" - "path/filepath" "strings" - "github.com/mholt/caddy/app" "github.com/mholt/caddy/middleware" "github.com/mholt/caddy/middleware/redirect" "github.com/mholt/caddy/server" "github.com/xenolf/lego/acme" ) -// Some essential values related to the Let's Encrypt process -const ( - // Size of RSA keys in bits - rsaKeySize = 2048 - - // The base URL to the Let's Encrypt CA - caURL = "http://192.168.99.100:4000" - - // The port to expose to the CA server for Simple HTTP Challenge - exposePort = "5001" -) - -// initiateLetsEncrypt sets up TLS for each server config -// in configs as needed. It only skips the config if the -// cert and key are already specified or if plaintext http -// is explicitly specified as the port. -func initiateLetsEncrypt(configs []server.Config) ([]server.Config, error) { +// Activate sets up TLS for each server config in configs +// as needed. It only skips the config if the cert and key +// are already provided or if plaintext http is explicitly +// specified as the port. +func Activate(configs []server.Config) ([]server.Config, error) { // populate map of email address to server configs that use that email address for TLS. // this will help us reduce roundtrips when getting the certs. initMap := make(map[string][]*server.Config) @@ -59,7 +38,7 @@ func initiateLetsEncrypt(configs []server.Config) ([]server.Config, error) { // than one certificate per email address, and still save them individually. for leEmail, serverConfigs := range initMap { // Look up or create the LE user account - leUser, err := getLetsEncryptUser(leEmail) + leUser, err := getUser(leEmail) if err != nil { return configs, err } @@ -79,11 +58,11 @@ func initiateLetsEncrypt(configs []server.Config) ([]server.Config, error) { // TODO: we can just do the agreement once, when registering, right? err = client.AgreeToTos() if err != nil { - saveLetsEncryptUser(leUser) // TODO: Might as well try, right? Error check? + saveUser(leUser) // TODO: Might as well try, right? Error check? return configs, errors.New("error agreeing to terms: " + err.Error()) } - err = saveLetsEncryptUser(leUser) + err = saveUser(leUser) if err != nil { return configs, errors.New("could not save user: " + err.Error()) } @@ -103,17 +82,16 @@ func initiateLetsEncrypt(configs []server.Config) ([]server.Config, error) { // ... that's it. save the certs, keys, and update server configs. for _, cert := range certificates { - certFolder := filepath.Join(app.DataFolder(), "letsencrypt", "sites", cert.Domain) - os.MkdirAll(certFolder, 0700) + os.MkdirAll(storage.Site(cert.Domain), 0700) // Save cert - err = saveCertificate(cert.Certificate, filepath.Join(certFolder, cert.Domain+".crt")) + err = saveCertificate(cert.Certificate, storage.SiteCertFile(cert.Domain)) if err != nil { return configs, err } // Save private key - err = ioutil.WriteFile(filepath.Join(certFolder, cert.Domain+".key"), cert.PrivateKey, 0600) + err = ioutil.WriteFile(storage.SiteKeyFile(cert.Domain), cert.PrivateKey, 0600) if err != nil { return configs, err } @@ -123,7 +101,7 @@ func initiateLetsEncrypt(configs []server.Config) ([]server.Config, error) { if err != nil { return configs, err } - err = ioutil.WriteFile(filepath.Join(certFolder, cert.Domain+".json"), jsonBytes, 0600) + err = ioutil.WriteFile(storage.SiteMetaFile(cert.Domain), jsonBytes, 0600) if err != nil { return configs, err } @@ -131,8 +109,8 @@ func initiateLetsEncrypt(configs []server.Config) ([]server.Config, error) { // it all comes down to this: filling in the file path of a valid certificate automatically for _, cfg := range serverConfigs { - cfg.TLS.Certificate = filepath.Join(app.DataFolder(), "letsencrypt", "sites", cfg.Host, cfg.Host+".crt") - cfg.TLS.Key = filepath.Join(app.DataFolder(), "letsencrypt", "sites", cfg.Host, cfg.Host+".key") + cfg.TLS.Certificate = storage.SiteCertFile(cfg.Host) + cfg.TLS.Key = storage.SiteKeyFile(cfg.Host) cfg.TLS.Enabled = true cfg.Port = "https" @@ -188,12 +166,12 @@ func getEmail(cfg server.Config) string { leEmail := cfg.TLS.LetsEncryptEmail if leEmail == "" { // Then try memory (command line flag or typed by user previously) - leEmail = LetsEncryptEmail + leEmail = DefaultEmail } if leEmail == "" { // Then try to get most recent user email ~/.caddy/users file // TODO: Probably better to open the user's json file and read the email out of there... - userDirs, err := ioutil.ReadDir(filepath.Join(app.DataFolder(), "letsencrypt", "users")) + userDirs, err := ioutil.ReadDir(storage.Users()) if err == nil { var mostRecent os.FileInfo for _, dir := range userDirs { @@ -204,7 +182,9 @@ func getEmail(cfg server.Config) string { mostRecent = dir } } - leEmail = mostRecent.Name() + if mostRecent != nil { + leEmail = mostRecent.Name() + } } } if leEmail == "" { @@ -216,135 +196,41 @@ func getEmail(cfg server.Config) string { if err != nil { return "" } - LetsEncryptEmail = leEmail + DefaultEmail = leEmail } return strings.TrimSpace(leEmail) } -func saveLetsEncryptUser(user LetsEncryptUser) error { - // make user account folder - userFolder := filepath.Join(app.DataFolder(), "letsencrypt", "users", user.Email) - err := os.MkdirAll(userFolder, 0700) - if err != nil { - return err - } +var ( + // Let's Encrypt account email to use if none provided + DefaultEmail string - // save private key file - user.KeyFile = filepath.Join(userFolder, emailUsername(user.Email)+".key") - err = savePrivateKey(user.key, user.KeyFile) - if err != nil { - return err - } + // Whether user has agreed to the Let's Encrypt SA + Agreed bool +) - // save registration file - jsonBytes, err := json.MarshalIndent(&user, "", "\t") - if err != nil { - return err - } +// Some essential values related to the Let's Encrypt process +const ( + // Size of RSA keys in bits + rsaKeySize = 2048 - return ioutil.WriteFile(filepath.Join(userFolder, "registration.json"), jsonBytes, 0600) -} + // The base URL to the Let's Encrypt CA + caURL = "http://192.168.99.100:4000" -func getLetsEncryptUser(email string) (LetsEncryptUser, error) { - var user LetsEncryptUser + // The port to expose to the CA server for Simple HTTP Challenge + exposePort = "5001" +) - userFolder := filepath.Join(app.DataFolder(), "letsencrypt", "users", email) - regFile, err := os.Open(filepath.Join(userFolder, "registration.json")) - if err != nil { - if os.IsNotExist(err) { - // create a new user - return newLetsEncryptUser(email) - } - return user, err - } +// KeySize represents the length of a key in bits +type KeySize int - err = json.NewDecoder(regFile).Decode(&user) - if err != nil { - return user, err - } - - user.key, err = loadPrivateKey(user.KeyFile) - if err != nil { - return user, err - } - - return user, nil -} - -func newLetsEncryptUser(email string) (LetsEncryptUser, error) { - user := LetsEncryptUser{Email: email} - privateKey, err := rsa.GenerateKey(rand.Reader, rsaKeySize) - if err != nil { - return user, errors.New("error generating private key: " + err.Error()) - } - user.key = privateKey - return user, nil -} - -func emailUsername(email string) string { - at := strings.Index(email, "@") - if at == -1 { - return email - } - return email[:at] -} - -type LetsEncryptUser struct { - Email string - Registration *acme.RegistrationResource - KeyFile string - key *rsa.PrivateKey -} - -func (u LetsEncryptUser) GetEmail() string { - return u.Email -} -func (u LetsEncryptUser) GetRegistration() *acme.RegistrationResource { - return u.Registration -} -func (u LetsEncryptUser) GetPrivateKey() *rsa.PrivateKey { - return u.key -} - -// savePrivateKey saves an RSA private key to file. -// -// Borrowed from Sebastian Erhart -// https://github.com/xenolf/lego/blob/34910bd541315993224af1f04f9b2877513e5477/crypto.go -func savePrivateKey(key *rsa.PrivateKey, file string) error { - pemKey := pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)} - keyOut, err := os.Create(file) - if err != nil { - return err - } - pem.Encode(keyOut, &pemKey) - keyOut.Close() - return nil -} - -// TODO: Check file permission -func saveCertificate(certBytes []byte, file string) error { - pemCert := pem.Block{Type: "CERTIFICATE", Bytes: certBytes} - certOut, err := os.Create(file) - if err != nil { - return err - } - pem.Encode(certOut, &pemCert) - certOut.Close() - return nil -} - -// loadPrivateKey loads an RSA private key from filename. -// -// Borrowed from Sebastian Erhart -// https://github.com/xenolf/lego/blob/34910bd541315993224af1f04f9b2877513e5477/crypto.go -func loadPrivateKey(file string) (*rsa.PrivateKey, error) { - keyBytes, err := ioutil.ReadFile(file) - if err != nil { - return nil, err - } - keyBlock, _ := pem.Decode(keyBytes) - return x509.ParsePKCS1PrivateKey(keyBlock.Bytes) -} +// Key sizes +const ( + ECC_224 KeySize = 224 + ECC_256 = 256 + RSA_2048 = 2048 + RSA_4096 = 4096 +) type CertificateMeta struct { Domain, URL string diff --git a/config/letsencrypt/storage.go b/config/letsencrypt/storage.go new file mode 100644 index 00000000..3f932b6d --- /dev/null +++ b/config/letsencrypt/storage.go @@ -0,0 +1,128 @@ +package letsencrypt + +import ( + "path/filepath" + "strings" + + "github.com/mholt/caddy/app" +) + +// storage is used to get file paths in a consistent, +// cross-platform way for persisting Let's Encrypt assets +// on the file system. +var storage = Storage(filepath.Join(app.DataFolder(), "letsencrypt")) + +// Storage is a root directory and facilitates +// forming file paths derived from it. +type Storage string + +func (s Storage) Path(parts ...string) string { + return filepath.Join(append([]string{string(s)}, parts...)...) +} + +// Sites gets the directory that stores site certificate and keys. +func (s Storage) Sites() string { + return filepath.Join(string(s), "sites") +} + +// Site returns the path to the folder containing assets for domain. +func (s Storage) Site(domain string) string { + return filepath.Join(s.Sites(), domain) +} + +// CertFile returns the path to the certificate file for domain. +func (s Storage) SiteCertFile(domain string) string { + return filepath.Join(s.Site(domain), domain+".crt") +} + +// SiteKeyFile returns the path to domain's private key file. +func (s Storage) SiteKeyFile(domain string) string { + return filepath.Join(s.Site(domain), domain+".key") +} + +// SiteMetaFile returns the path to the domain's asset metadata file. +func (s Storage) SiteMetaFile(domain string) string { + return filepath.Join(s.Site(domain), domain+".json") +} + +// Users gets the directory that stores account folders. +func (s Storage) Users() string { + return filepath.Join(string(s), "users") +} + +// User gets the account folder for the user with email. +func (s Storage) User(email string) string { + return filepath.Join(s.Users(), email) +} + +// UserRegFile gets the path to the registration file for +// the user with the given email address. +func (s Storage) UserRegFile(email string) string { + fileName := emailUsername(email) + if fileName == "" { + fileName = "registration" + } + return filepath.Join(s.User(email), fileName+".json") +} + +// UserKeyFile gets the path to the private key file for +// the user with the given email address. +func (s Storage) UserKeyFile(email string) string { + // TODO: Read the KeyFile property in the registration file instead? + fileName := emailUsername(email) + if fileName == "" { + fileName = "private" + } + return filepath.Join(s.User(email), fileName+".key") +} + +// emailUsername returns the username portion of an +// email address (part before '@') or the original +// input if it can't find the "@" symbol. +func emailUsername(email string) string { + at := strings.Index(email, "@") + if at == -1 { + return email + } + return email[:at] +} + +/* +// StorageDir is the full path to the folder where this Let's +// Encrypt client will set up camp. In other words, where it +// stores user account information, keys, and certificates. +// All files will be contained in a 'letsencrypt' folder +// within StorageDir. +// +// Changing this after the program has accessed this folder +// will result in undefined behavior. +var StorageDir = "." + +// Values related to persisting things on the file system +const ( + // ContainerDir is the name of the folder within StorageDir + // in which files or folders are placed. + ContainerDir = "letsencrypt" + + // File that contains information about the user's LE account + UserRegistrationFile = "registration.json" +) + +// BaseDir returns the full path to the base directory in which +// files or folders may be placed, e.g. "/letsencrypt". +func BaseDir() string { + return filepath.Join(StorageDir, ContainerDir) +} + +// AccountsDir returns the full path to the directory where account +// information is stored for LE users. +func AccountsDir() string { + return filepath.Join(BaseDir(), "users") +} + +// AccountsDir gets the full path to the directory for a certain +// user with the email address email. +func AccountDir(email string) string { + return filepath.Join(AccountsDir(), email) +} +*/ diff --git a/config/letsencrypt/user.go b/config/letsencrypt/user.go new file mode 100644 index 00000000..446bb677 --- /dev/null +++ b/config/letsencrypt/user.go @@ -0,0 +1,97 @@ +package letsencrypt + +import ( + "crypto/rand" + "crypto/rsa" + "encoding/json" + "errors" + "io/ioutil" + "os" + + "github.com/xenolf/lego/acme" +) + +type User struct { + Email string + Registration *acme.RegistrationResource + KeyFile string + key *rsa.PrivateKey +} + +func (u User) GetEmail() string { + return u.Email +} +func (u User) GetRegistration() *acme.RegistrationResource { + return u.Registration +} +func (u User) GetPrivateKey() *rsa.PrivateKey { + return u.key +} + +// getUser loads the user with the given email from disk. +func getUser(email string) (User, error) { + var user User + + // open user file + regFile, err := os.Open(storage.UserRegFile(email)) + if err != nil { + if os.IsNotExist(err) { + // create a new user + return newUser(email) + } + return user, err + } + defer regFile.Close() + + // load user information + err = json.NewDecoder(regFile).Decode(&user) + if err != nil { + return user, err + } + + // load their private key + user.key, err = loadRSAPrivateKey(user.KeyFile) + if err != nil { + return user, err + } + + return user, nil +} + +// saveUser persists a user's key and account registration +// to the file system. +func saveUser(user User) error { + // make user account folder + err := os.MkdirAll(storage.User(user.Email), 0700) + if err != nil { + return err + } + + // save private key file + user.KeyFile = storage.UserKeyFile(user.Email) + err = saveRSAPrivateKey(user.key, user.KeyFile) + if err != nil { + return err + } + + // save registration file + jsonBytes, err := json.MarshalIndent(&user, "", "\t") + if err != nil { + return err + } + + return ioutil.WriteFile(storage.UserRegFile(user.Email), jsonBytes, 0600) +} + +// newUser creates a new User for the given email address +// with a new private key. This function does not register +// the user via ACME. +func newUser(email string) (User, error) { + user := User{Email: email} + privateKey, err := rsa.GenerateKey(rand.Reader, rsaKeySize) + if err != nil { + return user, errors.New("error generating private key: " + err.Error()) + } + user.key = privateKey + return user, nil +} diff --git a/main.go b/main.go index 5405abe9..d194aef1 100644 --- a/main.go +++ b/main.go @@ -15,6 +15,7 @@ import ( "github.com/mholt/caddy/app" "github.com/mholt/caddy/config" + "github.com/mholt/caddy/config/letsencrypt" "github.com/mholt/caddy/server" ) @@ -33,8 +34,8 @@ func init() { flag.StringVar(&config.Host, "host", config.DefaultHost, "Default host") flag.StringVar(&config.Port, "port", config.DefaultPort, "Default port") flag.BoolVar(&version, "version", false, "Show version") - flag.BoolVar(&config.LetsEncryptAgree, "agree", false, "Agree to Let's Encrypt Subscriber Agreement") - flag.StringVar(&config.LetsEncryptEmail, "email", "", "Email address to use for Let's Encrypt account") + flag.BoolVar(&letsencrypt.Agreed, "agree", false, "Agree to Let's Encrypt Subscriber Agreement") + flag.StringVar(&letsencrypt.DefaultEmail, "email", "", "Default email address to use for Let's Encrypt transactions") } func main() { From 96ae288c4b932ccfdccea1eb17160183fc92cde2 Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Sat, 17 Oct 2015 20:44:33 -0600 Subject: [PATCH 18/73] More refactoring; cleaning up code, preparing for tests --- config/letsencrypt/letsencrypt.go | 254 ++++++++++++++++-------------- config/letsencrypt/storage.go | 40 ----- config/letsencrypt/user.go | 56 ++++++- 3 files changed, 186 insertions(+), 164 deletions(-) diff --git a/config/letsencrypt/letsencrypt.go b/config/letsencrypt/letsencrypt.go index 2782fc0d..98e5b4f2 100644 --- a/config/letsencrypt/letsencrypt.go +++ b/config/letsencrypt/letsencrypt.go @@ -1,14 +1,11 @@ package letsencrypt import ( - "bufio" "encoding/json" "errors" - "fmt" "io/ioutil" "net/http" "os" - "strings" "github.com/mholt/caddy/middleware" "github.com/mholt/caddy/middleware/redirect" @@ -20,91 +17,41 @@ import ( // as needed. It only skips the config if the cert and key // are already provided or if plaintext http is explicitly // specified as the port. +// +// This function may prompt the user to provide an email +// address if none is available through other means. It +// prefers the email address specified in the config, but +// if that is not available it will check the command line +// argument. If absent, it will use the most recent email +// address from last time. If there isn't one, the user +// will be prompted. If the user leaves email blank, . func Activate(configs []server.Config) ([]server.Config, error) { - // populate map of email address to server configs that use that email address for TLS. - // this will help us reduce roundtrips when getting the certs. - initMap := make(map[string][]*server.Config) - for i := 0; i < len(configs); i++ { - if configs[i].TLS.Certificate == "" && configs[i].TLS.Key == "" && configs[i].Port != "http" { // TODO: && !cfg.Host.IsLoopback() - leEmail := getEmail(configs[i]) - if leEmail == "" { - return configs, errors.New("cannot serve HTTPS without email address OR certificate and key") - } - initMap[leEmail] = append(initMap[leEmail], &configs[i]) - } + // Group configs by LE email address; this will help us + // reduce round-trips when getting the certs. + initMap, err := groupConfigsByEmail(configs) + if err != nil { + return configs, err } // Loop through each email address and obtain certs; we can obtain more // than one certificate per email address, and still save them individually. for leEmail, serverConfigs := range initMap { - // Look up or create the LE user account - leUser, err := getUser(leEmail) + // make client to service this email address with CA server + client, err := newClient(leEmail) if err != nil { return configs, err } - // The client facilitates our communication with the CA server. - client := acme.NewClient(caURL, &leUser, rsaKeySize, exposePort) - - // 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 err != nil { - return configs, errors.New("registration error: " + err.Error()) - } - leUser.Registration = reg - - // TODO: we can just do the agreement once, when registering, right? - err = client.AgreeToTos() - if err != nil { - saveUser(leUser) // TODO: Might as well try, right? Error check? - return configs, errors.New("error agreeing to terms: " + err.Error()) - } - - err = saveUser(leUser) - if err != nil { - return configs, errors.New("could not save user: " + err.Error()) - } - } - - // collect all the hostnames into one slice - var hosts []string - for _, cfg := range serverConfigs { - hosts = append(hosts, cfg.Host) - } - - // showtime: let's get free, trusted SSL certificates! yeah! - certificates, err := client.ObtainCertificates(hosts) + // client is ready, so let's get free, trusted SSL certificates! yeah! + certificates, err := obtainCertificates(client, serverConfigs) if err != nil { - return configs, errors.New("error obtaining certs: " + err.Error()) + return configs, err } - // ... that's it. save the certs, keys, and update server configs. - for _, cert := range certificates { - os.MkdirAll(storage.Site(cert.Domain), 0700) - - // Save cert - err = saveCertificate(cert.Certificate, storage.SiteCertFile(cert.Domain)) - if err != nil { - return configs, err - } - - // Save private key - err = ioutil.WriteFile(storage.SiteKeyFile(cert.Domain), cert.PrivateKey, 0600) - if err != nil { - return configs, err - } - - // Save cert metadata - jsonBytes, err := json.MarshalIndent(&CertificateMeta{URL: cert.CertURL, Domain: cert.Domain}, "", "\t") - if err != nil { - return configs, err - } - err = ioutil.WriteFile(storage.SiteMetaFile(cert.Domain), jsonBytes, 0600) - if err != nil { - return configs, err - } + // ... that's it. save the certs, keys, and metadata files to disk + err = saveCertsAndKeys(certificates) + if err != nil { + return configs, err } // it all comes down to this: filling in the file path of a valid certificate automatically @@ -134,9 +81,117 @@ func Activate(configs []server.Config) ([]server.Config, error) { return configs, nil } -// redirPlaintextHost returns a new virtualhost configuration for a server -// that redirects the plaintext HTTP host of cfg to cfg, which is assumed -// to be the secure (HTTPS) host. +// groupConfigsByEmail groups configs by the Let's Encrypt email address +// associated to them or to the default Let's Encrypt email address. If the +// default email is not available, the user will be prompted to provide one. +func groupConfigsByEmail(configs []server.Config) (map[string][]*server.Config, error) { + initMap := make(map[string][]*server.Config) + for i := 0; i < len(configs); i++ { + if configs[i].TLS.Certificate == "" && configs[i].TLS.Key == "" && configs[i].Port != "http" { // TODO: && !cfg.Host.IsLoopback() + leEmail := getEmail(configs[i]) + if leEmail == "" { + return nil, errors.New("must have email address to serve HTTPS without existing certificate and key") + } + initMap[leEmail] = append(initMap[leEmail], &configs[i]) + } + } + return initMap, nil +} + +// newClient creates a new ACME client to facilitate communication +// with the Let's Encrypt CA server on behalf of the user specified +// by leEmail. As part of this process, a user will be loaded from +// disk (if already exists) or created new and registered via ACME +// and saved to the file system for next time. +func newClient(leEmail string) (*acme.Client, error) { + // Look up or create the LE user account + leUser, err := getUser(leEmail) + if err != nil { + return nil, err + } + + // The client facilitates our communication with the CA server. + client := acme.NewClient(caURL, &leUser, rsaKeySize, exposePort) + + // 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 err != nil { + return nil, errors.New("registration error: " + err.Error()) + } + leUser.Registration = reg + + // TODO: we can just do the agreement once: when registering, right? + err = client.AgreeToTos() + if err != nil { + saveUser(leUser) // TODO: Might as well try, right? Error check? + return nil, errors.New("error agreeing to terms: " + err.Error()) + } + + // save user to the file system + err = saveUser(leUser) + if err != nil { + return nil, errors.New("could not save user: " + err.Error()) + } + } + + return client, nil +} + +// obtainCertificates obtains certificates from the CA server for +// the configurations in serverConfigs using client. +func obtainCertificates(client *acme.Client, serverConfigs []*server.Config) ([]acme.CertificateResource, error) { + // collect all the hostnames into one slice + var hosts []string + for _, cfg := range serverConfigs { + hosts = append(hosts, cfg.Host) + } + + certificates, err := client.ObtainCertificates(hosts) + if err != nil { + return nil, errors.New("error obtaining certs: " + err.Error()) + } + + return certificates, nil +} + +// saveCertificates saves each certificate resource to disk. This +// includes the certificate file itself, the private key, and the +// metadata file. +func saveCertsAndKeys(certificates []acme.CertificateResource) error { + for _, cert := range certificates { + os.MkdirAll(storage.Site(cert.Domain), 0700) + + // Save cert + err := saveCertificate(cert.Certificate, storage.SiteCertFile(cert.Domain)) + if err != nil { + return err + } + + // Save private key + err = ioutil.WriteFile(storage.SiteKeyFile(cert.Domain), cert.PrivateKey, 0600) + if err != nil { + return err + } + + // Save cert metadata + jsonBytes, err := json.MarshalIndent(&CertificateMeta{URL: cert.CertURL, Domain: cert.Domain}, "", "\t") + if err != nil { + return err + } + err = ioutil.WriteFile(storage.SiteMetaFile(cert.Domain), jsonBytes, 0600) + if err != nil { + return err + } + } + return nil +} + +// redirPlaintextHost returns a new plaintext HTTP configuration for +// a virtualHost that simply redirects to cfg, which is assumed to +// be the HTTPS configuration. The returned configuration is set +// to listen on the "http" port (port 80). func redirPlaintextHost(cfg server.Config) server.Config { redirMidware := func(next middleware.Handler) middleware.Handler { return redirect.Redirect{Next: next, Rules: []redirect.Rule{ @@ -158,49 +213,6 @@ func redirPlaintextHost(cfg server.Config) server.Config { } } -// getEmail does everything it can to obtain an email -// address from the user to use for TLS for cfg. If it -// cannot get an email address, it returns empty string. -func getEmail(cfg server.Config) string { - // First try the tls directive from the Caddyfile - leEmail := cfg.TLS.LetsEncryptEmail - if leEmail == "" { - // Then try memory (command line flag or typed by user previously) - leEmail = DefaultEmail - } - if leEmail == "" { - // Then try to get most recent user email ~/.caddy/users file - // TODO: Probably better to open the user's json file and read the email out of there... - userDirs, err := ioutil.ReadDir(storage.Users()) - if err == nil { - var mostRecent os.FileInfo - for _, dir := range userDirs { - if !dir.IsDir() { - continue - } - if mostRecent == nil || dir.ModTime().After(mostRecent.ModTime()) { - mostRecent = dir - } - } - if mostRecent != nil { - leEmail = mostRecent.Name() - } - } - } - if leEmail == "" { - // Alas, we must bother the user and ask for an email address - reader := bufio.NewReader(os.Stdin) - fmt.Print("Email address: ") // TODO: More explanation probably, and show ToS? - var err error - leEmail, err = reader.ReadString('\n') - if err != nil { - return "" - } - DefaultEmail = leEmail - } - return strings.TrimSpace(leEmail) -} - var ( // Let's Encrypt account email to use if none provided DefaultEmail string diff --git a/config/letsencrypt/storage.go b/config/letsencrypt/storage.go index 3f932b6d..74d56151 100644 --- a/config/letsencrypt/storage.go +++ b/config/letsencrypt/storage.go @@ -86,43 +86,3 @@ func emailUsername(email string) string { } return email[:at] } - -/* -// StorageDir is the full path to the folder where this Let's -// Encrypt client will set up camp. In other words, where it -// stores user account information, keys, and certificates. -// All files will be contained in a 'letsencrypt' folder -// within StorageDir. -// -// Changing this after the program has accessed this folder -// will result in undefined behavior. -var StorageDir = "." - -// Values related to persisting things on the file system -const ( - // ContainerDir is the name of the folder within StorageDir - // in which files or folders are placed. - ContainerDir = "letsencrypt" - - // File that contains information about the user's LE account - UserRegistrationFile = "registration.json" -) - -// BaseDir returns the full path to the base directory in which -// files or folders may be placed, e.g. "/letsencrypt". -func BaseDir() string { - return filepath.Join(StorageDir, ContainerDir) -} - -// AccountsDir returns the full path to the directory where account -// information is stored for LE users. -func AccountsDir() string { - return filepath.Join(BaseDir(), "users") -} - -// AccountsDir gets the full path to the directory for a certain -// user with the email address email. -func AccountDir(email string) string { - return filepath.Join(AccountsDir(), email) -} -*/ diff --git a/config/letsencrypt/user.go b/config/letsencrypt/user.go index 446bb677..f66acee4 100644 --- a/config/letsencrypt/user.go +++ b/config/letsencrypt/user.go @@ -1,13 +1,17 @@ package letsencrypt import ( + "bufio" "crypto/rand" "crypto/rsa" "encoding/json" "errors" + "fmt" "io/ioutil" "os" + "strings" + "github.com/mholt/caddy/server" "github.com/xenolf/lego/acme" ) @@ -29,6 +33,7 @@ func (u User) GetPrivateKey() *rsa.PrivateKey { } // getUser loads the user with the given email from disk. +// If the user does not exist, it will create a new one. func getUser(email string) (User, error) { var user User @@ -59,7 +64,7 @@ func getUser(email string) (User, error) { } // saveUser persists a user's key and account registration -// to the file system. +// to the file system. It does NOT register the user via ACME. func saveUser(user User) error { // make user account folder err := os.MkdirAll(storage.User(user.Email), 0700) @@ -84,8 +89,10 @@ func saveUser(user User) error { } // newUser creates a new User for the given email address -// with a new private key. This function does not register -// the user via ACME. +// with a new private key. This function does NOT save the +// user to disk or register it via ACME. If you want to use +// a user account that might already exist, call getUser +// instead. func newUser(email string) (User, error) { user := User{Email: email} privateKey, err := rsa.GenerateKey(rand.Reader, rsaKeySize) @@ -95,3 +102,46 @@ func newUser(email string) (User, error) { user.key = privateKey return user, nil } + +// getEmail does everything it can to obtain an email +// address from the user to use for TLS for cfg. If it +// cannot get an email address, it returns empty string. +func getEmail(cfg server.Config) string { + // First try the tls directive from the Caddyfile + leEmail := cfg.TLS.LetsEncryptEmail + if leEmail == "" { + // Then try memory (command line flag or typed by user previously) + leEmail = DefaultEmail + } + if leEmail == "" { + // Then try to get most recent user email ~/.caddy/users file + // TODO: Probably better to open the user's json file and read the email out of there... + userDirs, err := ioutil.ReadDir(storage.Users()) + if err == nil { + var mostRecent os.FileInfo + for _, dir := range userDirs { + if !dir.IsDir() { + continue + } + if mostRecent == nil || dir.ModTime().After(mostRecent.ModTime()) { + mostRecent = dir + } + } + if mostRecent != nil { + leEmail = mostRecent.Name() + } + } + } + if leEmail == "" { + // Alas, we must bother the user and ask for an email address + reader := bufio.NewReader(os.Stdin) + fmt.Print("Email address: ") // TODO: More explanation probably, and show ToS? + var err error + leEmail, err = reader.ReadString('\n') + if err != nil { + return "" + } + DefaultEmail = leEmail + } + return strings.TrimSpace(leEmail) +} From a75663501da240f2029272802571f70c98a00a9b Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Sat, 17 Oct 2015 20:51:46 -0600 Subject: [PATCH 19/73] Little more refactoring in letsencrypt --- config/letsencrypt/letsencrypt.go | 51 ++++++++++++++++++------------- 1 file changed, 30 insertions(+), 21 deletions(-) diff --git a/config/letsencrypt/letsencrypt.go b/config/letsencrypt/letsencrypt.go index 98e5b4f2..3a6a71d1 100644 --- a/config/letsencrypt/letsencrypt.go +++ b/config/letsencrypt/letsencrypt.go @@ -55,27 +55,7 @@ func Activate(configs []server.Config) ([]server.Config, error) { } // it all comes down to this: filling in the file path of a valid certificate automatically - for _, cfg := range serverConfigs { - cfg.TLS.Certificate = storage.SiteCertFile(cfg.Host) - cfg.TLS.Key = storage.SiteKeyFile(cfg.Host) - cfg.TLS.Enabled = true - cfg.Port = "https" - - // Is there a plaintext HTTP config for the same host? If not, make - // one and have it redirect all requests to this HTTPS host. - var plaintextHostFound bool - for _, otherCfg := range configs { - if cfg.Host == otherCfg.Host && otherCfg.Port == "http" { - plaintextHostFound = true - break - } - } - - if !plaintextHostFound { - // Make one that redirects to HTTPS for all requests - configs = append(configs, redirPlaintextHost(*cfg)) - } - } + configs = autoConfigure(configs, serverConfigs) } return configs, nil @@ -188,6 +168,35 @@ func saveCertsAndKeys(certificates []acme.CertificateResource) error { return nil } +// autoConfigure enables TLS on all the configs in serverConfigs +// and appends, if necessary, new configs to allConfigs that redirect +// plaintext HTTP to their HTTPS counterparts. +func autoConfigure(allConfigs []server.Config, serverConfigs []*server.Config) []server.Config { + for _, cfg := range serverConfigs { + cfg.TLS.Certificate = storage.SiteCertFile(cfg.Host) + cfg.TLS.Key = storage.SiteKeyFile(cfg.Host) + cfg.TLS.Enabled = true + cfg.Port = "https" + + // Is there a plaintext HTTP config for the same host? If not, make + // one and have it redirect all requests to this HTTPS host. + var plaintextHostFound bool + for _, otherCfg := range allConfigs { + if cfg.Host == otherCfg.Host && otherCfg.Port == "http" { + plaintextHostFound = true + break + } + } + + if !plaintextHostFound { + // Make one that redirects to HTTPS for all requests + allConfigs = append(allConfigs, redirPlaintextHost(*cfg)) + } + } + + return allConfigs +} + // redirPlaintextHost returns a new plaintext HTTP configuration for // a virtualHost that simply redirects to cfg, which is assumed to // be the HTTPS configuration. The returned configuration is set From 7568b0e215eff3ba4c1b17bbe37758edb7a36ebb Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Sat, 17 Oct 2015 21:00:48 -0600 Subject: [PATCH 20/73] Compatibility with latest lego commits (dev mode enabled) --- config/letsencrypt/letsencrypt.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/letsencrypt/letsencrypt.go b/config/letsencrypt/letsencrypt.go index 3a6a71d1..93f402c1 100644 --- a/config/letsencrypt/letsencrypt.go +++ b/config/letsencrypt/letsencrypt.go @@ -91,7 +91,7 @@ func newClient(leEmail string) (*acme.Client, error) { } // The client facilitates our communication with the CA server. - client := acme.NewClient(caURL, &leUser, rsaKeySize, exposePort) + client := acme.NewClient(caURL, &leUser, rsaKeySize, exposePort, true) // TODO: Dev mode is enabled // If not registered, the user must register an account with the CA // and agree to terms From 9f9de389d5e2567c1ee58b79dc51a00cc7f4eaf1 Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Sat, 17 Oct 2015 21:18:46 -0600 Subject: [PATCH 21/73] lego provides PEM-encoded certificate bytes for us --- config/letsencrypt/crypto.go | 13 ------------- config/letsencrypt/letsencrypt.go | 2 +- 2 files changed, 1 insertion(+), 14 deletions(-) diff --git a/config/letsencrypt/crypto.go b/config/letsencrypt/crypto.go index 5c84b4e4..3322cc1c 100644 --- a/config/letsencrypt/crypto.go +++ b/config/letsencrypt/crypto.go @@ -8,19 +8,6 @@ import ( "os" ) -// saveCertificate saves a DER-encoded (binary format) certificate -// to file. -func saveCertificate(certBytes []byte, file string) error { - pemCert := pem.Block{Type: "CERTIFICATE", Bytes: certBytes} - certOut, err := os.Create(file) - if err != nil { - return err - } - pem.Encode(certOut, &pemCert) - certOut.Close() - return nil -} - // loadRSAPrivateKey loads a PEM-encoded RSA private key from file. func loadRSAPrivateKey(file string) (*rsa.PrivateKey, error) { keyBytes, err := ioutil.ReadFile(file) diff --git a/config/letsencrypt/letsencrypt.go b/config/letsencrypt/letsencrypt.go index 93f402c1..2e8a173e 100644 --- a/config/letsencrypt/letsencrypt.go +++ b/config/letsencrypt/letsencrypt.go @@ -144,7 +144,7 @@ func saveCertsAndKeys(certificates []acme.CertificateResource) error { os.MkdirAll(storage.Site(cert.Domain), 0700) // Save cert - err := saveCertificate(cert.Certificate, storage.SiteCertFile(cert.Domain)) + err := ioutil.WriteFile(storage.SiteCertFile(cert.Domain), cert.Certificate, 0600) if err != nil { return err } From da8a4fafcc078ed038da1fcfae9c4d005f0fbb0e Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Sat, 17 Oct 2015 22:55:50 -0600 Subject: [PATCH 22/73] letsencrypt: Use existing certs & keys if already in storage --- config/letsencrypt/letsencrypt.go | 86 ++++++++++++++++++++++--------- 1 file changed, 61 insertions(+), 25 deletions(-) diff --git a/config/letsencrypt/letsencrypt.go b/config/letsencrypt/letsencrypt.go index 2e8a173e..dd968090 100644 --- a/config/letsencrypt/letsencrypt.go +++ b/config/letsencrypt/letsencrypt.go @@ -1,3 +1,5 @@ +// Package letsencrypt integrates Let's Encrypt with Caddy with first-class support. +// It is designed to configure sites for HTTPS by default. package letsencrypt import ( @@ -26,6 +28,15 @@ import ( // address from last time. If there isn't one, the user // will be prompted. If the user leaves email blank, . func Activate(configs []server.Config) ([]server.Config, error) { + // First identify and configure any elligible hosts for which + // we already have certs and keys in storage from last time. + configLen := len(configs) // avoid infinite loop since this loop appends to the slice + for i := 0; i < configLen; i++ { + if existingCertAndKey(configs[i].Host) { + configs = autoConfigure(&configs[i], configs) + } + } + // Group configs by LE email address; this will help us // reduce round-trips when getting the certs. initMap, err := groupConfigsByEmail(configs) @@ -54,8 +65,10 @@ func Activate(configs []server.Config) ([]server.Config, error) { return configs, err } - // it all comes down to this: filling in the file path of a valid certificate automatically - configs = autoConfigure(configs, serverConfigs) + // it all comes down to this: turning TLS on for all the configs + for _, cfg := range serverConfigs { + configs = autoConfigure(cfg, configs) + } } return configs, nil @@ -64,10 +77,18 @@ func Activate(configs []server.Config) ([]server.Config, error) { // groupConfigsByEmail groups configs by the Let's Encrypt email address // associated to them or to the default Let's Encrypt email address. If the // default email is not available, the user will be prompted to provide one. +// +// This function also filters out configs that don't need extra TLS help. +// Configurations with a manual TLS configuration or one that is already +// found in storage will not be added to any group. func groupConfigsByEmail(configs []server.Config) (map[string][]*server.Config, error) { initMap := make(map[string][]*server.Config) for i := 0; i < len(configs); i++ { if configs[i].TLS.Certificate == "" && configs[i].TLS.Key == "" && configs[i].Port != "http" { // TODO: && !cfg.Host.IsLoopback() + // make sure an HTTPS version of this config doesn't exist in the list already + if hostHasOtherScheme(configs[i].Host, "https", configs) { + continue + } leEmail := getEmail(configs[i]) if leEmail == "" { return nil, errors.New("must have email address to serve HTTPS without existing certificate and key") @@ -78,6 +99,20 @@ func groupConfigsByEmail(configs []server.Config) (map[string][]*server.Config, return initMap, nil } +// existingCertAndKey returns true if the host has a certificate +// and private key in storage already, false otherwise. +func existingCertAndKey(host string) bool { + _, err := os.Stat(storage.SiteCertFile(host)) + if err != nil { + return false + } + _, err = os.Stat(storage.SiteKeyFile(host)) + if err != nil { + return false + } + return true +} + // newClient creates a new ACME client to facilitate communication // with the Let's Encrypt CA server on behalf of the user specified // by leEmail. As part of this process, a user will be loaded from @@ -168,33 +203,34 @@ func saveCertsAndKeys(certificates []acme.CertificateResource) error { return nil } -// autoConfigure enables TLS on all the configs in serverConfigs -// and appends, if necessary, new configs to allConfigs that redirect -// plaintext HTTP to their HTTPS counterparts. -func autoConfigure(allConfigs []server.Config, serverConfigs []*server.Config) []server.Config { - for _, cfg := range serverConfigs { - cfg.TLS.Certificate = storage.SiteCertFile(cfg.Host) - cfg.TLS.Key = storage.SiteKeyFile(cfg.Host) - cfg.TLS.Enabled = true - cfg.Port = "https" +// autoConfigure enables TLS on cfg and appends, if necessary, a new config +// to allConfigs that redirects plaintext HTTP to its new HTTPS counterpart. +func autoConfigure(cfg *server.Config, allConfigs []server.Config) []server.Config { + cfg.TLS.Certificate = storage.SiteCertFile(cfg.Host) + cfg.TLS.Key = storage.SiteKeyFile(cfg.Host) + cfg.TLS.Enabled = true + cfg.Port = "https" - // Is there a plaintext HTTP config for the same host? If not, make - // one and have it redirect all requests to this HTTPS host. - var plaintextHostFound bool - for _, otherCfg := range allConfigs { - if cfg.Host == otherCfg.Host && otherCfg.Port == "http" { - plaintextHostFound = true - break - } - } + // Is there a plaintext HTTP config for the same host? If not, make + // one and have it redirect all requests to this HTTPS host. + if !hostHasOtherScheme(cfg.Host, "http", allConfigs) { + // Make one that redirects to HTTPS for all requests + allConfigs = append(allConfigs, redirPlaintextHost(*cfg)) + } + return allConfigs +} - if !plaintextHostFound { - // Make one that redirects to HTTPS for all requests - allConfigs = append(allConfigs, redirPlaintextHost(*cfg)) +// hostHasOtherScheme tells you whether there is another config in the list +// for the same host but with the port equal to scheme. For example, to see +// if example.com has a https variant already, pass in example.com and +// "https" along with the list of configs. +func hostHasOtherScheme(host, scheme string, allConfigs []server.Config) bool { + for _, otherCfg := range allConfigs { + if otherCfg.Host == host && otherCfg.Port == scheme { + return true } } - - return allConfigs + return false } // redirPlaintextHost returns a new plaintext HTTP configuration for From 8cd6b8aa996ea027b5d6c73829d6c3921323ce35 Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Sat, 17 Oct 2015 23:35:59 -0600 Subject: [PATCH 23/73] letsencrypt: Tests for load/save RSA keys and redirPlaintextHost --- config/letsencrypt/crypto_test.go | 40 ++++++++++++++++++++ config/letsencrypt/letsencrypt_test.go | 51 ++++++++++++++++++++++++++ config/letsencrypt/storage.go | 4 -- 3 files changed, 91 insertions(+), 4 deletions(-) create mode 100644 config/letsencrypt/crypto_test.go create mode 100644 config/letsencrypt/letsencrypt_test.go diff --git a/config/letsencrypt/crypto_test.go b/config/letsencrypt/crypto_test.go new file mode 100644 index 00000000..c99c54c1 --- /dev/null +++ b/config/letsencrypt/crypto_test.go @@ -0,0 +1,40 @@ +package letsencrypt + +import ( + "bytes" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "os" + "testing" +) + +func TestSaveAndLoadRSAPrivateKey(t *testing.T) { + keyFile := "test.key" + defer os.Remove(keyFile) + + privateKey, err := rsa.GenerateKey(rand.Reader, 256) // small key size is OK for testing + if err != nil { + t.Fatal(err) + } + privateKeyPEM := pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)} + + // test save + err = saveRSAPrivateKey(privateKey, keyFile) + if err != nil { + t.Fatal("error saving private key:", err) + } + + // test load + loadedKey, err := loadRSAPrivateKey(keyFile) + if err != nil { + t.Error("error loading private key:", err) + } + loadedKeyPEM := pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(loadedKey)} + + // very loaded key is correct + if !bytes.Equal(loadedKeyPEM.Bytes, privateKeyPEM.Bytes) { + t.Error("Expected key bytes to be the same, but they weren't") + } +} diff --git a/config/letsencrypt/letsencrypt_test.go b/config/letsencrypt/letsencrypt_test.go new file mode 100644 index 00000000..841ec54b --- /dev/null +++ b/config/letsencrypt/letsencrypt_test.go @@ -0,0 +1,51 @@ +package letsencrypt + +import ( + "net/http" + "testing" + + "github.com/mholt/caddy/middleware/redirect" + "github.com/mholt/caddy/server" +) + +func TestRedirPlaintextHost(t *testing.T) { + cfg := redirPlaintextHost(server.Config{ + Host: "example.com", + Port: "http", + }) + + // Check host and port + if actual, expected := cfg.Host, "example.com"; actual != expected { + t.Errorf("Expected redir config to have host %s but got %s", expected, actual) + } + if actual, expected := cfg.Port, "http"; actual != expected { + t.Errorf("Expected redir config to have port '%s' but got '%s'", expected, actual) + } + + // Make sure redirect handler is set up properly + if cfg.Middleware == nil || len(cfg.Middleware["/"]) != 1 { + t.Fatalf("Redir config middleware not set up properly; got: %#v", cfg.Middleware) + } + + handler, ok := cfg.Middleware["/"][0](nil).(redirect.Redirect) + if !ok { + t.Fatalf("Expected a redirect.Redirect middleware, but got: %#v", handler) + } + if len(handler.Rules) != 1 { + t.Fatalf("Expected one redirect rule, got: %#v", handler.Rules) + } + + // Check redirect rule for correctness + if actual, expected := handler.Rules[0].FromScheme, "http"; actual != expected { + t.Errorf("Expected redirect rule to be from scheme '%s' but is actually from '%s'", expected, actual) + } + if actual, expected := handler.Rules[0].FromPath, "/"; actual != expected { + t.Errorf("Expected redirect rule to be for path '%s' but is actually for '%s'", expected, actual) + } + if actual, expected := handler.Rules[0].To, "https://example.com{uri}"; actual != expected { + t.Errorf("Expected redirect rule to be to URL '%s' but is actually to '%s'", expected, actual) + } + if actual, expected := handler.Rules[0].Code, http.StatusMovedPermanently; actual != expected { + t.Errorf("Expected redirect rule to have code %d but was %d", expected, actual) + } +} diff --git a/config/letsencrypt/storage.go b/config/letsencrypt/storage.go index 74d56151..54a89599 100644 --- a/config/letsencrypt/storage.go +++ b/config/letsencrypt/storage.go @@ -16,10 +16,6 @@ var storage = Storage(filepath.Join(app.DataFolder(), "letsencrypt")) // forming file paths derived from it. type Storage string -func (s Storage) Path(parts ...string) string { - return filepath.Join(append([]string{string(s)}, parts...)...) -} - // Sites gets the directory that stores site certificate and keys. func (s Storage) Sites() string { return filepath.Join(string(s), "sites") From d764111886ef60840e0c4d1adf7d75cbbc8ec10d Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Sun, 18 Oct 2015 10:39:28 -0600 Subject: [PATCH 24/73] letsencrypt: Storage tests --- config/letsencrypt/storage.go | 2 + config/letsencrypt/storage_test.go | 66 ++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 config/letsencrypt/storage_test.go diff --git a/config/letsencrypt/storage.go b/config/letsencrypt/storage.go index 54a89599..ca4405a8 100644 --- a/config/letsencrypt/storage.go +++ b/config/letsencrypt/storage.go @@ -79,6 +79,8 @@ func emailUsername(email string) string { at := strings.Index(email, "@") if at == -1 { return email + } else if at == 0 { + return email[1:] } return email[:at] } diff --git a/config/letsencrypt/storage_test.go b/config/letsencrypt/storage_test.go new file mode 100644 index 00000000..812f5501 --- /dev/null +++ b/config/letsencrypt/storage_test.go @@ -0,0 +1,66 @@ +package letsencrypt + +import "testing" + +func TestStorage(t *testing.T) { + storage = Storage("./letsencrypt") + + if expected, actual := "letsencrypt/sites", storage.Sites(); actual != expected { + t.Errorf("Expected Sites() to return '%s' but got '%s'", expected, actual) + } + if expected, actual := "letsencrypt/sites/test.com", storage.Site("test.com"); actual != expected { + t.Errorf("Expected Site() to return '%s' but got '%s'", expected, actual) + } + if expected, actual := "letsencrypt/sites/test.com/test.com.crt", storage.SiteCertFile("test.com"); actual != expected { + t.Errorf("Expected SiteCertFile() to return '%s' but got '%s'", expected, actual) + } + if expected, actual := "letsencrypt/sites/test.com/test.com.key", storage.SiteKeyFile("test.com"); actual != expected { + t.Errorf("Expected SiteKeyFile() to return '%s' but got '%s'", expected, actual) + } + if expected, actual := "letsencrypt/sites/test.com/test.com.json", storage.SiteMetaFile("test.com"); actual != expected { + t.Errorf("Expected SiteMetaFile() to return '%s' but got '%s'", expected, actual) + } + if expected, actual := "letsencrypt/users", storage.Users(); actual != expected { + t.Errorf("Expected Users() to return '%s' but got '%s'", expected, actual) + } + if expected, actual := "letsencrypt/users/me@example.com", storage.User("me@example.com"); actual != expected { + t.Errorf("Expected User() to return '%s' but got '%s'", expected, actual) + } + if expected, actual := "letsencrypt/users/me@example.com/me.json", storage.UserRegFile("me@example.com"); actual != expected { + t.Errorf("Expected UserRegFile() to return '%s' but got '%s'", expected, actual) + } + if expected, actual := "letsencrypt/users/me@example.com/me.key", storage.UserKeyFile("me@example.com"); actual != expected { + t.Errorf("Expected UserKeyFile() to return '%s' but got '%s'", expected, actual) + } +} + +func TestEmailUsername(t *testing.T) { + for i, test := range []struct { + input, expect string + }{ + { + input: "username@example.com", + expect: "username", + }, + { + input: "plus+addressing@example.com", + expect: "plus+addressing", + }, + { + input: "me+plus-addressing@example.com", + expect: "me+plus-addressing", + }, + { + input: "not-an-email", + expect: "not-an-email", + }, + { + input: "@foobar.com", + expect: "foobar.com", + }, + } { + if actual := emailUsername(test.input); actual != test.expect { + t.Errorf("Test %d: Expected username to be '%s' but was '%s'", i, test.expect, actual) + } + } +} From 42ac2d2dde81b8ff92aae80e93432b10bd9b0dca Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Sun, 18 Oct 2015 12:09:06 -0600 Subject: [PATCH 25/73] letsencrypt: More tests, tests for user.go & slight refactoring --- config/letsencrypt/crypto_test.go | 21 +++- config/letsencrypt/letsencrypt.go | 21 ++-- config/letsencrypt/user.go | 20 +++- config/letsencrypt/user_test.go | 192 ++++++++++++++++++++++++++++++ 4 files changed, 238 insertions(+), 16 deletions(-) create mode 100644 config/letsencrypt/user_test.go diff --git a/config/letsencrypt/crypto_test.go b/config/letsencrypt/crypto_test.go index c99c54c1..938778a8 100644 --- a/config/letsencrypt/crypto_test.go +++ b/config/letsencrypt/crypto_test.go @@ -5,20 +5,22 @@ import ( "crypto/rand" "crypto/rsa" "crypto/x509" - "encoding/pem" "os" "testing" ) +func init() { + rsaKeySizeToUse = 128 // makes tests faster +} + func TestSaveAndLoadRSAPrivateKey(t *testing.T) { keyFile := "test.key" defer os.Remove(keyFile) - privateKey, err := rsa.GenerateKey(rand.Reader, 256) // small key size is OK for testing + privateKey, err := rsa.GenerateKey(rand.Reader, 128) // small key size is OK for testing if err != nil { t.Fatal(err) } - privateKeyPEM := pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)} // test save err = saveRSAPrivateKey(privateKey, keyFile) @@ -31,10 +33,19 @@ func TestSaveAndLoadRSAPrivateKey(t *testing.T) { if err != nil { t.Error("error loading private key:", err) } - loadedKeyPEM := pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(loadedKey)} // very loaded key is correct - if !bytes.Equal(loadedKeyPEM.Bytes, privateKeyPEM.Bytes) { + if !rsaPrivateKeysSame(privateKey, loadedKey) { t.Error("Expected key bytes to be the same, but they weren't") } } + +// rsaPrivateKeyBytes returns the bytes of DER-encoded key. +func rsaPrivateKeyBytes(key *rsa.PrivateKey) []byte { + return x509.MarshalPKCS1PrivateKey(key) +} + +// rsaPrivateKeysSame compares the bytes of a and b and returns true if they are the same. +func rsaPrivateKeysSame(a, b *rsa.PrivateKey) bool { + return bytes.Equal(rsaPrivateKeyBytes(a), rsaPrivateKeyBytes(b)) +} diff --git a/config/letsencrypt/letsencrypt.go b/config/letsencrypt/letsencrypt.go index dd968090..58dfcd78 100644 --- a/config/letsencrypt/letsencrypt.go +++ b/config/letsencrypt/letsencrypt.go @@ -1,5 +1,6 @@ -// Package letsencrypt integrates Let's Encrypt with Caddy with first-class support. -// It is designed to configure sites for HTTPS by default. +// Package letsencrypt integrates Let's Encrypt functionality into Caddy +// with first-class support for creating and renewing certificates +// automatically. It is designed to configure sites for HTTPS by default. package letsencrypt import ( @@ -126,7 +127,7 @@ func newClient(leEmail string) (*acme.Client, error) { } // The client facilitates our communication with the CA server. - client := acme.NewClient(caURL, &leUser, rsaKeySize, exposePort, true) // TODO: Dev mode is enabled + client := acme.NewClient(caURL, &leUser, rsaKeySizeToUse, exposePort, true) // TODO: Dev mode is enabled // If not registered, the user must register an account with the CA // and agree to terms @@ -268,9 +269,6 @@ var ( // Some essential values related to the Let's Encrypt process const ( - // Size of RSA keys in bits - rsaKeySize = 2048 - // The base URL to the Let's Encrypt CA caURL = "http://192.168.99.100:4000" @@ -278,10 +276,10 @@ const ( exposePort = "5001" ) -// KeySize represents the length of a key in bits +// KeySize represents the length of a key in bits. type KeySize int -// Key sizes +// Key sizes are used to determine the strength of a key. const ( ECC_224 KeySize = 224 ECC_256 = 256 @@ -289,6 +287,13 @@ const ( RSA_4096 = 4096 ) +// rsaKeySizeToUse is the size to use for new RSA keys. +// This shouldn't need to change except for in tests; +// the size can be drastically reduced for speed. +var rsaKeySizeToUse = RSA_2048 + +// CertificateMeta is a container type used to write out a file +// with information about a certificate. type CertificateMeta struct { Domain, URL string } diff --git a/config/letsencrypt/user.go b/config/letsencrypt/user.go index f66acee4..752cc510 100644 --- a/config/letsencrypt/user.go +++ b/config/letsencrypt/user.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "io/ioutil" "os" "strings" @@ -15,6 +16,7 @@ import ( "github.com/xenolf/lego/acme" ) +// User represents a Let's Encrypt user account. type User struct { Email string Registration *acme.RegistrationResource @@ -22,18 +24,25 @@ type User struct { key *rsa.PrivateKey } +// GetEmail gets u's email. func (u User) GetEmail() string { return u.Email } + +// GetRegistration gets u's registration resource. func (u User) GetRegistration() *acme.RegistrationResource { return u.Registration } + +// GetPrivateKey gets u's private key. func (u User) GetPrivateKey() *rsa.PrivateKey { return u.key } // getUser loads the user with the given email from disk. -// If the user does not exist, it will create a new one. +// If the user does not exist, it will create a new one, +// but it does NOT save new users to the disk or register +// them via ACME. func getUser(email string) (User, error) { var user User @@ -95,7 +104,7 @@ func saveUser(user User) error { // instead. func newUser(email string) (User, error) { user := User{Email: email} - privateKey, err := rsa.GenerateKey(rand.Reader, rsaKeySize) + privateKey, err := rsa.GenerateKey(rand.Reader, rsaKeySizeToUse) if err != nil { return user, errors.New("error generating private key: " + err.Error()) } @@ -134,7 +143,8 @@ func getEmail(cfg server.Config) string { } if leEmail == "" { // Alas, we must bother the user and ask for an email address - reader := bufio.NewReader(os.Stdin) + // TODO/BUG: This doesn't work when Caddyfile is piped into caddy + reader := bufio.NewReader(stdin) fmt.Print("Email address: ") // TODO: More explanation probably, and show ToS? var err error leEmail, err = reader.ReadString('\n') @@ -145,3 +155,7 @@ func getEmail(cfg server.Config) string { } return strings.TrimSpace(leEmail) } + +// stdin is used to read the user's input if prompted; +// this is changed by tests during tests. +var stdin = io.ReadWriter(os.Stdin) diff --git a/config/letsencrypt/user_test.go b/config/letsencrypt/user_test.go new file mode 100644 index 00000000..d074856a --- /dev/null +++ b/config/letsencrypt/user_test.go @@ -0,0 +1,192 @@ +package letsencrypt + +import ( + "bytes" + "crypto/rand" + "crypto/rsa" + "io" + "os" + "strings" + "testing" + "time" + + "github.com/mholt/caddy/server" + "github.com/xenolf/lego/acme" +) + +func TestUser(t *testing.T) { + privateKey, err := rsa.GenerateKey(rand.Reader, 128) + if err != nil { + t.Fatalf("Could not generate test private key: %v", err) + } + u := User{ + Email: "me@mine.com", + Registration: new(acme.RegistrationResource), + key: privateKey, + } + + if expected, actual := "me@mine.com", u.GetEmail(); actual != expected { + t.Errorf("Expected email '%s' but got '%s'", expected, actual) + } + if u.GetRegistration() == nil { + t.Error("Expected a registration resource, but got nil") + } + if expected, actual := privateKey, u.GetPrivateKey(); actual != expected { + t.Errorf("Expected the private key at address %p but got one at %p instead ", expected, actual) + } +} + +func TestNewUser(t *testing.T) { + email := "me@foobar.com" + user, err := newUser(email) + if err != nil { + t.Fatalf("Error creating user: %v", err) + } + if user.key == nil { + t.Error("Private key is nil") + } + if user.Email != email { + t.Errorf("Expected email to be %s, but was %s", email, user.Email) + } + if user.Registration != nil { + t.Error("New user already has a registration resource; it shouldn't") + } +} + +func TestSaveUser(t *testing.T) { + storage = Storage("./testdata") + defer os.RemoveAll(string(storage)) + + email := "me@foobar.com" + user, err := newUser(email) + if err != nil { + t.Fatalf("Error creating user: %v", err) + } + + err = saveUser(user) + if err != nil { + t.Fatalf("Error saving user: %v", err) + } + _, err = os.Stat(storage.UserRegFile(email)) + if err != nil { + t.Errorf("Cannot access user registration file, error: %v", err) + } + _, err = os.Stat(storage.UserKeyFile(email)) + if err != nil { + t.Errorf("Cannot access user private key file, error: %v", err) + } +} + +func TestGetUserDoesNotAlreadyExist(t *testing.T) { + storage = Storage("./testdata") + defer os.RemoveAll(string(storage)) + + user, err := getUser("user_does_not_exist@foobar.com") + if err != nil { + t.Fatalf("Error getting user: %v", err) + } + + if user.key == nil { + t.Error("Expected user to have a private key, but it was nil") + } +} + +func TestGetUserAlreadyExists(t *testing.T) { + storage = Storage("./testdata") + defer os.RemoveAll(string(storage)) + + email := "me@foobar.com" + + // Set up test + user, err := newUser(email) + if err != nil { + t.Fatalf("Error creating user: %v", err) + } + err = saveUser(user) + if err != nil { + t.Fatalf("Error saving user: %v", err) + } + + // Expect to load user from disk + user2, err := getUser(email) + if err != nil { + t.Fatalf("Error getting user: %v", err) + } + + // Assert keys are the same + if !rsaPrivateKeysSame(user.key, user2.key) { + t.Error("Expected private key to be the same after loading, but it wasn't") + } + + // Assert emails are the same + if user.Email != user2.Email { + t.Errorf("Expected emails to be equal, but was '%s' before and '%s' after loading", user.Email, user2.Email) + } +} + +func TestGetEmail(t *testing.T) { + storage = Storage("./testdata") + defer os.RemoveAll(string(storage)) + DefaultEmail = "test2@foo.com" + + // Test1: Use email in config + config := server.Config{ + TLS: server.TLSConfig{ + LetsEncryptEmail: "test1@foo.com", + }, + } + actual := getEmail(config) + if actual != "test1@foo.com" { + t.Errorf("Did not get correct email from config; expected '%s' but got '%s'", "test1@foo.com", actual) + } + + // Test2: Use default email from flag (or user previously typing it) + actual = getEmail(server.Config{}) + if actual != DefaultEmail { + t.Errorf("Did not get correct email from config; expected '%s' but got '%s'", DefaultEmail, actual) + } + + // Test3: Get input from user + DefaultEmail = "" + stdin = new(bytes.Buffer) + _, 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(server.Config{}) + 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) + } + + // Test4: Get most recent email from before + DefaultEmail = "" + for i, eml := range []string{ + "test4-3@foo.com", + "test4-2@foo.com", + "test4-1@foo.com", + } { + u, err := newUser(eml) + if err != nil { + t.Fatalf("Error creating user %d: %v", i, err) + } + err = saveUser(u) + if err != nil { + t.Fatalf("Error saving user %d: %v", i, err) + } + + // Change modified time so they're all different, so the test becomes deterministic + f, err := os.Stat(storage.User(eml)) + if err != nil { + t.Fatalf("Could not access user folder for '%s': %v", eml, err) + } + chTime := f.ModTime().Add(-(time.Duration(i) * time.Second)) + if err := os.Chtimes(storage.User(eml), chTime, chTime); err != nil { + t.Fatalf("Could not change user folder mod time for '%s': %v", eml, err) + } + } + + actual = getEmail(server.Config{}) + 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) + } +} From cc1ff93250f938dc8fc24d4d757148e2c5111574 Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Sun, 18 Oct 2015 12:12:33 -0600 Subject: [PATCH 26/73] letsencrypt: Fix Windows tests --- config/letsencrypt/storage_test.go | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/config/letsencrypt/storage_test.go b/config/letsencrypt/storage_test.go index 812f5501..67368669 100644 --- a/config/letsencrypt/storage_test.go +++ b/config/letsencrypt/storage_test.go @@ -1,35 +1,38 @@ package letsencrypt -import "testing" +import ( + "path/filepath" + "testing" +) func TestStorage(t *testing.T) { storage = Storage("./letsencrypt") - if expected, actual := "letsencrypt/sites", storage.Sites(); actual != expected { + if expected, actual := filepath.Join("letsencrypt", "sites"), storage.Sites(); actual != expected { t.Errorf("Expected Sites() to return '%s' but got '%s'", expected, actual) } - if expected, actual := "letsencrypt/sites/test.com", storage.Site("test.com"); actual != expected { + if expected, actual := filepath.Join("letsencrypt", "sites", "test.com"), storage.Site("test.com"); actual != expected { t.Errorf("Expected Site() to return '%s' but got '%s'", expected, actual) } - if expected, actual := "letsencrypt/sites/test.com/test.com.crt", storage.SiteCertFile("test.com"); actual != expected { + if expected, actual := filepath.Join("letsencrypt", "sites", "test.com", "test.com.crt"), storage.SiteCertFile("test.com"); actual != expected { t.Errorf("Expected SiteCertFile() to return '%s' but got '%s'", expected, actual) } - if expected, actual := "letsencrypt/sites/test.com/test.com.key", storage.SiteKeyFile("test.com"); actual != expected { + if expected, actual := filepath.Join("letsencrypt", "sites", "test.com", "test.com.key"), storage.SiteKeyFile("test.com"); actual != expected { t.Errorf("Expected SiteKeyFile() to return '%s' but got '%s'", expected, actual) } - if expected, actual := "letsencrypt/sites/test.com/test.com.json", storage.SiteMetaFile("test.com"); actual != expected { + if expected, actual := filepath.Join("letsencrypt", "sites", "test.com", "test.com.json"), storage.SiteMetaFile("test.com"); actual != expected { t.Errorf("Expected SiteMetaFile() to return '%s' but got '%s'", expected, actual) } - if expected, actual := "letsencrypt/users", storage.Users(); actual != expected { + if expected, actual := filepath.Join("letsencrypt", "users"), storage.Users(); actual != expected { t.Errorf("Expected Users() to return '%s' but got '%s'", expected, actual) } - if expected, actual := "letsencrypt/users/me@example.com", storage.User("me@example.com"); actual != expected { + if expected, actual := filepath.Join("letsencrypt", "users", "me@example.com"), storage.User("me@example.com"); actual != expected { t.Errorf("Expected User() to return '%s' but got '%s'", expected, actual) } - if expected, actual := "letsencrypt/users/me@example.com/me.json", storage.UserRegFile("me@example.com"); actual != expected { + if expected, actual := filepath.Join("letsencrypt", "users", "me@example.com", "me.json"), storage.UserRegFile("me@example.com"); actual != expected { t.Errorf("Expected UserRegFile() to return '%s' but got '%s'", expected, actual) } - if expected, actual := "letsencrypt/users/me@example.com/me.key", storage.UserKeyFile("me@example.com"); actual != expected { + if expected, actual := filepath.Join("letsencrypt", "users", "me@example.com", "me.key"), storage.UserKeyFile("me@example.com"); actual != expected { t.Errorf("Expected UserKeyFile() to return '%s' but got '%s'", expected, actual) } } From c0ebe31560fa0dcdbab5b9e1c3f5669ddfff852a Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Sun, 18 Oct 2015 19:27:51 -0600 Subject: [PATCH 27/73] Fix ServerBlockStorage so it actually stores stuff --- config/config.go | 1 + 1 file changed, 1 insertion(+) diff --git a/config/config.go b/config/config.go index 1e61cd9e..f1d5fcce 100644 --- a/config/config.go +++ b/config/config.go @@ -90,6 +90,7 @@ func Load(filename string, input io.Reader) (Group, error) { // TODO: For now, we only support the default path scope / config.Middleware["/"] = append(config.Middleware["/"], midware) } + storages[dir.name] = controller.ServerBlockStorage // persist for this server block } } From cd0b47d0683ff2e33ab7ed15ec4eb5321c4d4826 Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Sun, 18 Oct 2015 22:50:42 -0600 Subject: [PATCH 28/73] letsencrypt: Don't auto-configure loopback hosts or 'tls off' User can specify 'tls off" in Caddyfile to force-disable automatic HTTPS configuration --- config/letsencrypt/letsencrypt.go | 48 ++++++++++++++++++++++--------- config/setup/tls.go | 4 +++ 2 files changed, 38 insertions(+), 14 deletions(-) diff --git a/config/letsencrypt/letsencrypt.go b/config/letsencrypt/letsencrypt.go index 58dfcd78..13745098 100644 --- a/config/letsencrypt/letsencrypt.go +++ b/config/letsencrypt/letsencrypt.go @@ -9,6 +9,7 @@ import ( "io/ioutil" "net/http" "os" + "strings" "github.com/mholt/caddy/middleware" "github.com/mholt/caddy/middleware/redirect" @@ -33,7 +34,7 @@ func Activate(configs []server.Config) ([]server.Config, error) { // we already have certs and keys in storage from last time. configLen := len(configs) // avoid infinite loop since this loop appends to the slice for i := 0; i < configLen; i++ { - if existingCertAndKey(configs[i].Host) { + if existingCertAndKey(configs[i].Host) && configs[i].TLS.LetsEncryptEmail != "off" { configs = autoConfigure(&configs[i], configs) } } @@ -83,19 +84,37 @@ func Activate(configs []server.Config) ([]server.Config, error) { // Configurations with a manual TLS configuration or one that is already // found in storage will not be added to any group. func groupConfigsByEmail(configs []server.Config) (map[string][]*server.Config, error) { + // configQualifies returns true if cfg qualifes for automatic LE activation + configQualifies := func(cfg server.Config) bool { + return cfg.TLS.Certificate == "" && // user could provide their own cert and key + cfg.TLS.Key == "" && + + // user can force-disable automatic HTTPS for this host + cfg.Port != "http" && + cfg.TLS.LetsEncryptEmail != "off" && + + // obviously we get can't certs for loopback or internal hosts + cfg.Host != "localhost" && + cfg.Host != "" && + cfg.Host != "0.0.0.0" && + cfg.Host != "::1" && + !strings.HasPrefix(cfg.Host, "127.") && + !strings.HasPrefix(cfg.Host, "10.") && + + // make sure an HTTPS version of this config doesn't exist in the list already + !hostHasOtherScheme(cfg.Host, "https", configs) + } + initMap := make(map[string][]*server.Config) for i := 0; i < len(configs); i++ { - if configs[i].TLS.Certificate == "" && configs[i].TLS.Key == "" && configs[i].Port != "http" { // TODO: && !cfg.Host.IsLoopback() - // make sure an HTTPS version of this config doesn't exist in the list already - if hostHasOtherScheme(configs[i].Host, "https", configs) { - continue - } - leEmail := getEmail(configs[i]) - if leEmail == "" { - return nil, errors.New("must have email address to serve HTTPS without existing certificate and key") - } - initMap[leEmail] = append(initMap[leEmail], &configs[i]) + if !configQualifies(configs[i]) { + continue } + leEmail := getEmail(configs[i]) + if leEmail == "" { + return nil, errors.New("must have email address to serve HTTPS without existing certificate and key") + } + initMap[leEmail] = append(initMap[leEmail], &configs[i]) } return initMap, nil } @@ -212,12 +231,12 @@ func autoConfigure(cfg *server.Config, allConfigs []server.Config) []server.Conf cfg.TLS.Enabled = true cfg.Port = "https" - // Is there a plaintext HTTP config for the same host? If not, make - // one and have it redirect all requests to this HTTPS host. + // Set up http->https redirect as long as there isn't already + // a http counterpart in the configs if !hostHasOtherScheme(cfg.Host, "http", allConfigs) { - // Make one that redirects to HTTPS for all requests allConfigs = append(allConfigs, redirPlaintextHost(*cfg)) } + return allConfigs } @@ -270,6 +289,7 @@ var ( // Some essential values related to the Let's Encrypt process const ( // The base URL to the Let's Encrypt CA + // TODO: Staging API URL is: https://acme-staging.api.letsencrypt.org caURL = "http://192.168.99.100:4000" // The port to expose to the CA server for Simple HTTP Challenge diff --git a/config/setup/tls.go b/config/setup/tls.go index 1ddef0b3..89d6e467 100644 --- a/config/setup/tls.go +++ b/config/setup/tls.go @@ -21,6 +21,10 @@ func TLS(c *Controller) (middleware.Middleware, error) { switch len(args) { case 1: c.TLS.LetsEncryptEmail = args[0] + // user can force-disable LE activation this way + if c.TLS.LetsEncryptEmail == "off" { + c.TLS.Enabled = false + } case 2: c.TLS.Certificate = args[0] c.TLS.Key = args[1] From c626774da2ea96e52f9d57e96f6647e7a792e221 Mon Sep 17 00:00:00 2001 From: xenolf Date: Tue, 20 Oct 2015 02:44:00 +0200 Subject: [PATCH 29/73] First, raw renewal implementation. Pretty basic :D --- config/letsencrypt/letsencrypt.go | 146 +++++++++++++++++++++++++++++- config/letsencrypt/storage.go | 5 + 2 files changed, 150 insertions(+), 1 deletion(-) diff --git a/config/letsencrypt/letsencrypt.go b/config/letsencrypt/letsencrypt.go index 13745098..4f33cf74 100644 --- a/config/letsencrypt/letsencrypt.go +++ b/config/letsencrypt/letsencrypt.go @@ -7,9 +7,11 @@ import ( "encoding/json" "errors" "io/ioutil" + "log" "net/http" "os" "strings" + "time" "github.com/mholt/caddy/middleware" "github.com/mholt/caddy/middleware/redirect" @@ -38,6 +40,8 @@ func Activate(configs []server.Config) ([]server.Config, error) { configs = autoConfigure(&configs[i], configs) } } + // Handle cert renewal on Startup + processCertificateRenewal(configs) // Group configs by LE email address; this will help us // reduce round-trips when getting the certs. @@ -73,6 +77,8 @@ func Activate(configs []server.Config) ([]server.Config, error) { } } + go renewalFunc(configs) + return configs, nil } @@ -211,7 +217,7 @@ func saveCertsAndKeys(certificates []acme.CertificateResource) error { } // Save cert metadata - jsonBytes, err := json.MarshalIndent(&CertificateMeta{URL: cert.CertURL, Domain: cert.Domain}, "", "\t") + jsonBytes, err := json.MarshalIndent(&cert, "", "\t") if err != nil { return err } @@ -278,6 +284,141 @@ func redirPlaintextHost(cfg server.Config) server.Config { } } +func renewalFunc(configs []server.Config) { + nextRun, err := processCertificateRenewal(configs) + if err != nil { + log.Printf("[ERROR] Could not start renewal routine. %v", err) + return + } + + for { + timer := time.NewTimer(time.Duration(nextRun) * time.Hour) + <-timer.C + nextRun, err = processCertificateRenewal(configs) + if err != nil { + log.Printf("[ERROR] Renewal routing stopped. %v", err) + return + } + } +} + +// checkCertificateRenewal loops through all configured +// sites and looks for certificates to renew. Nothing is mutated +// through this function. The changes happen directly on disk. +func processCertificateRenewal(configs []server.Config) (int, error) { + log.Print("[INFO] Processing certificate renewals...") + // Check if we should run. If not, get out of here. + next, err := getNextRenewalShedule() + if err != nil { + return 0, err + } + + if next > 0 { + return next, nil + } + + // We are executing. Write the current timestamp into the file. + err = ioutil.WriteFile(storage.RenewTimerFile(), []byte(time.Now().UTC().Format(time.RFC3339)), 0600) + if err != nil { + return 0, err + } + next = renewTimer + + for _, cfg := range configs { + // Check if this entry is TLS enabled and managed by LE + if !cfg.TLS.Enabled || !existingCertAndKey(cfg.Host) { + continue + } + + // Read the certificate and get the NotAfter time. + certBytes, err := ioutil.ReadFile(storage.SiteCertFile(cfg.Host)) + if err != nil { + return 0, err + } + expTime, err := acme.GetPEMCertExpiration(certBytes) + if err != nil { + return 0, err + } + + // The time returned from the certificate is always in UTC. + // So calculate the time left with local time as UTC. + // Directly convert it to days for the following checks. + daysLeft := int(expTime.Sub(time.Now().UTC()).Hours() / 24) + + // Renew on two or less days remaining. + if daysLeft <= 2 { + log.Printf("[WARN] There are %d days left on the certificate of %s. Trying to renew now.", daysLeft, cfg.Host) + client, err := newClient(getEmail(cfg)) + if err != nil { + return 0, err + } + + // Read metadata + metaBytes, err := ioutil.ReadFile(storage.SiteMetaFile(cfg.Host)) + if err != nil { + return 0, err + } + + privBytes, err := ioutil.ReadFile(storage.SiteKeyFile(cfg.Host)) + if err != nil { + return 0, err + } + + var certMeta acme.CertificateResource + err = json.Unmarshal(metaBytes, &certMeta) + certMeta.Certificate = certBytes + certMeta.PrivateKey = privBytes + + // Renew certificate. + // TODO: revokeOld should be an option in the caddyfile + newCertMeta, err := client.RenewCertificate(certMeta, true) + if err != nil { + return 0, err + } + + saveCertsAndKeys([]acme.CertificateResource{newCertMeta}) + } + + // Warn on 14 days remaining + if daysLeft <= 14 { + log.Printf("[WARN] There are %d days left on the certificate of %s. Will renew on two days left.\n", daysLeft, cfg.Host) + } + } + + return next, nil +} + +// getNextRenewalShedule calculates the offset in hours the renew process should +// run from the current time. If the file the time is in does not exists, the +// function returns zero to trigger a renew asap. +func getNextRenewalShedule() (int, error) { + + // Check if the file exists. If it does not, return 0 to indicate immediate processing. + if _, err := os.Stat(storage.RenewTimerFile()); os.IsNotExist(err) { + return 0, nil + } + + renewTimeBytes, err := ioutil.ReadFile(storage.RenewTimerFile()) + if err != nil { + return 0, err + } + + renewalTime, err := time.Parse(time.RFC3339, string(renewTimeBytes)) + if err != nil { + return 0, err + } + + // The time read from the file was equal or more then 24 hours in the past, + // write the current time to the file and return true. + hoursSinceRenew := int(time.Now().UTC().Sub(renewalTime).Hours()) + + if hoursSinceRenew >= renewTimer { + return 0, nil + } + + return hoursSinceRenew, nil +} + var ( // Let's Encrypt account email to use if none provided DefaultEmail string @@ -294,6 +435,9 @@ const ( // The port to expose to the CA server for Simple HTTP Challenge exposePort = "5001" + + // Renewal Timer - Check renewals every x hours. + renewTimer = 24 ) // KeySize represents the length of a key in bits. diff --git a/config/letsencrypt/storage.go b/config/letsencrypt/storage.go index ca4405a8..05279820 100644 --- a/config/letsencrypt/storage.go +++ b/config/letsencrypt/storage.go @@ -16,6 +16,11 @@ var storage = Storage(filepath.Join(app.DataFolder(), "letsencrypt")) // forming file paths derived from it. type Storage string +// RenewTimerFile returns the path to the file used for renewal timing. +func (s Storage) RenewTimerFile() string { + return filepath.Join(string(s), "lastrenew") +} + // Sites gets the directory that stores site certificate and keys. func (s Storage) Sites() string { return filepath.Join(string(s), "sites") From 38885e4301299d496c2e244b2fffa1844c12e2d0 Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Tue, 20 Oct 2015 20:16:01 -0600 Subject: [PATCH 30/73] Simplify timing mechanism for checking renewals --- config/letsencrypt/letsencrypt.go | 146 ++---------------------------- config/letsencrypt/renew.go | 94 +++++++++++++++++++ config/letsencrypt/storage.go | 5 - 3 files changed, 100 insertions(+), 145 deletions(-) create mode 100644 config/letsencrypt/renew.go diff --git a/config/letsencrypt/letsencrypt.go b/config/letsencrypt/letsencrypt.go index 4f33cf74..2d9b3cf1 100644 --- a/config/letsencrypt/letsencrypt.go +++ b/config/letsencrypt/letsencrypt.go @@ -7,7 +7,6 @@ import ( "encoding/json" "errors" "io/ioutil" - "log" "net/http" "os" "strings" @@ -40,7 +39,8 @@ func Activate(configs []server.Config) ([]server.Config, error) { configs = autoConfigure(&configs[i], configs) } } - // Handle cert renewal on Startup + + // First renew any existing certificates that need it processCertificateRenewal(configs) // Group configs by LE email address; this will help us @@ -77,7 +77,7 @@ func Activate(configs []server.Config) ([]server.Config, error) { } } - go renewalFunc(configs) + go keepCertificatesRenewed(configs) return configs, nil } @@ -284,141 +284,6 @@ func redirPlaintextHost(cfg server.Config) server.Config { } } -func renewalFunc(configs []server.Config) { - nextRun, err := processCertificateRenewal(configs) - if err != nil { - log.Printf("[ERROR] Could not start renewal routine. %v", err) - return - } - - for { - timer := time.NewTimer(time.Duration(nextRun) * time.Hour) - <-timer.C - nextRun, err = processCertificateRenewal(configs) - if err != nil { - log.Printf("[ERROR] Renewal routing stopped. %v", err) - return - } - } -} - -// checkCertificateRenewal loops through all configured -// sites and looks for certificates to renew. Nothing is mutated -// through this function. The changes happen directly on disk. -func processCertificateRenewal(configs []server.Config) (int, error) { - log.Print("[INFO] Processing certificate renewals...") - // Check if we should run. If not, get out of here. - next, err := getNextRenewalShedule() - if err != nil { - return 0, err - } - - if next > 0 { - return next, nil - } - - // We are executing. Write the current timestamp into the file. - err = ioutil.WriteFile(storage.RenewTimerFile(), []byte(time.Now().UTC().Format(time.RFC3339)), 0600) - if err != nil { - return 0, err - } - next = renewTimer - - for _, cfg := range configs { - // Check if this entry is TLS enabled and managed by LE - if !cfg.TLS.Enabled || !existingCertAndKey(cfg.Host) { - continue - } - - // Read the certificate and get the NotAfter time. - certBytes, err := ioutil.ReadFile(storage.SiteCertFile(cfg.Host)) - if err != nil { - return 0, err - } - expTime, err := acme.GetPEMCertExpiration(certBytes) - if err != nil { - return 0, err - } - - // The time returned from the certificate is always in UTC. - // So calculate the time left with local time as UTC. - // Directly convert it to days for the following checks. - daysLeft := int(expTime.Sub(time.Now().UTC()).Hours() / 24) - - // Renew on two or less days remaining. - if daysLeft <= 2 { - log.Printf("[WARN] There are %d days left on the certificate of %s. Trying to renew now.", daysLeft, cfg.Host) - client, err := newClient(getEmail(cfg)) - if err != nil { - return 0, err - } - - // Read metadata - metaBytes, err := ioutil.ReadFile(storage.SiteMetaFile(cfg.Host)) - if err != nil { - return 0, err - } - - privBytes, err := ioutil.ReadFile(storage.SiteKeyFile(cfg.Host)) - if err != nil { - return 0, err - } - - var certMeta acme.CertificateResource - err = json.Unmarshal(metaBytes, &certMeta) - certMeta.Certificate = certBytes - certMeta.PrivateKey = privBytes - - // Renew certificate. - // TODO: revokeOld should be an option in the caddyfile - newCertMeta, err := client.RenewCertificate(certMeta, true) - if err != nil { - return 0, err - } - - saveCertsAndKeys([]acme.CertificateResource{newCertMeta}) - } - - // Warn on 14 days remaining - if daysLeft <= 14 { - log.Printf("[WARN] There are %d days left on the certificate of %s. Will renew on two days left.\n", daysLeft, cfg.Host) - } - } - - return next, nil -} - -// getNextRenewalShedule calculates the offset in hours the renew process should -// run from the current time. If the file the time is in does not exists, the -// function returns zero to trigger a renew asap. -func getNextRenewalShedule() (int, error) { - - // Check if the file exists. If it does not, return 0 to indicate immediate processing. - if _, err := os.Stat(storage.RenewTimerFile()); os.IsNotExist(err) { - return 0, nil - } - - renewTimeBytes, err := ioutil.ReadFile(storage.RenewTimerFile()) - if err != nil { - return 0, err - } - - renewalTime, err := time.Parse(time.RFC3339, string(renewTimeBytes)) - if err != nil { - return 0, err - } - - // The time read from the file was equal or more then 24 hours in the past, - // write the current time to the file and return true. - hoursSinceRenew := int(time.Now().UTC().Sub(renewalTime).Hours()) - - if hoursSinceRenew >= renewTimer { - return 0, nil - } - - return hoursSinceRenew, nil -} - var ( // Let's Encrypt account email to use if none provided DefaultEmail string @@ -431,13 +296,14 @@ var ( const ( // The base URL to the Let's Encrypt CA // TODO: Staging API URL is: https://acme-staging.api.letsencrypt.org + // TODO: Production endpoint is: https://acme-v01.api.letsencrypt.org caURL = "http://192.168.99.100:4000" // The port to expose to the CA server for Simple HTTP Challenge exposePort = "5001" - // Renewal Timer - Check renewals every x hours. - renewTimer = 24 + // How often to check certificates for renewal + renewInterval = 24 * time.Hour ) // KeySize represents the length of a key in bits. diff --git a/config/letsencrypt/renew.go b/config/letsencrypt/renew.go new file mode 100644 index 00000000..4bc992ba --- /dev/null +++ b/config/letsencrypt/renew.go @@ -0,0 +1,94 @@ +package letsencrypt + +import ( + "encoding/json" + "io/ioutil" + "log" + "time" + + "github.com/mholt/caddy/server" + "github.com/xenolf/lego/acme" +) + +// keepCertificatesRenewed is a permanently-blocking function +// that loops indefinitely and, on a regular schedule, checks +// certificates for expiration and initiates a renewal of certs +// that are expiring soon. +func keepCertificatesRenewed(configs []server.Config) { + ticker := time.Tick(renewInterval) + for range ticker { + if err := processCertificateRenewal(configs); err != nil { + log.Printf("[ERROR] cert renewal: %v", err) + } + } +} + +// checkCertificateRenewal loops through all configured +// sites and looks for certificates to renew. Nothing is mutated +// through this function. The changes happen directly on disk. +func processCertificateRenewal(configs []server.Config) error { + log.Print("[INFO] Processing certificate renewals...") + + for _, cfg := range configs { + // Check if this entry is TLS enabled and managed by LE + if !cfg.TLS.Enabled || !existingCertAndKey(cfg.Host) { + continue + } + + // Read the certificate and get the NotAfter time. + certBytes, err := ioutil.ReadFile(storage.SiteCertFile(cfg.Host)) + if err != nil { + return err + } + expTime, err := acme.GetPEMCertExpiration(certBytes) + if err != nil { + return err + } + + // The time returned from the certificate is always in UTC. + // So calculate the time left with local time as UTC. + // Directly convert it to days for the following checks. + daysLeft := int(expTime.Sub(time.Now().UTC()).Hours() / 24) + + // Renew on two or less days remaining. + if daysLeft <= 2 { + log.Printf("[WARN] There are %d days left on the certificate of %s. Trying to renew now.", daysLeft, cfg.Host) + client, err := newClient(getEmail(cfg)) + if err != nil { + return err + } + + // Read metadata + metaBytes, err := ioutil.ReadFile(storage.SiteMetaFile(cfg.Host)) + if err != nil { + return err + } + + privBytes, err := ioutil.ReadFile(storage.SiteKeyFile(cfg.Host)) + if err != nil { + return err + } + + var certMeta acme.CertificateResource + err = json.Unmarshal(metaBytes, &certMeta) + certMeta.Certificate = certBytes + certMeta.PrivateKey = privBytes + + // Renew certificate. + // TODO: revokeOld should be an option in the caddyfile + newCertMeta, err := client.RenewCertificate(certMeta, true) + if err != nil { + return err + } + + saveCertsAndKeys([]acme.CertificateResource{newCertMeta}) + } + + // Warn on 14 days remaining + if daysLeft <= 14 { + log.Printf("[WARN] There are %d days left on the certificate of %s. Will renew on two days left.\n", daysLeft, cfg.Host) + } + } + + return nil +} diff --git a/config/letsencrypt/storage.go b/config/letsencrypt/storage.go index 05279820..ca4405a8 100644 --- a/config/letsencrypt/storage.go +++ b/config/letsencrypt/storage.go @@ -16,11 +16,6 @@ var storage = Storage(filepath.Join(app.DataFolder(), "letsencrypt")) // forming file paths derived from it. type Storage string -// RenewTimerFile returns the path to the file used for renewal timing. -func (s Storage) RenewTimerFile() string { - return filepath.Join(string(s), "lastrenew") -} - // Sites gets the directory that stores site certificate and keys. func (s Storage) Sites() string { return filepath.Join(string(s), "sites") From a16beb98de580d91f3c6e5621b32a6fade2014ab Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Wed, 21 Oct 2015 00:09:45 -0600 Subject: [PATCH 31/73] letsencrypt: Revoke certificate --- config/letsencrypt/letsencrypt.go | 41 ++++++++++++++++++++++++++----- main.go | 10 ++++++++ 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/config/letsencrypt/letsencrypt.go b/config/letsencrypt/letsencrypt.go index 4f33cf74..fed137ad 100644 --- a/config/letsencrypt/letsencrypt.go +++ b/config/letsencrypt/letsencrypt.go @@ -419,6 +419,41 @@ func getNextRenewalShedule() (int, error) { return hoursSinceRenew, nil } +// Revoke revokes the certificate for host via ACME protocol. +func Revoke(host string) error { + if !existingCertAndKey(host) { + return errors.New("no certificate and key for " + host) + } + + email := getEmail(server.Config{Host: host}) + if email == "" { + return errors.New("email is required to revoke") + } + + client, err := newClient(email) + if err != nil { + return err + } + + certFile := storage.SiteCertFile(host) + certBytes, err := ioutil.ReadFile(certFile) + if err != nil { + return err + } + + err = client.RevokeCertificate(certBytes) + if err != nil { + return err + } + + err = os.Remove(certFile) + if err != nil { + return errors.New("certificate revoked, but unable to delete certificate file: " + err.Error()) + } + + return nil +} + var ( // Let's Encrypt account email to use if none provided DefaultEmail string @@ -455,9 +490,3 @@ const ( // This shouldn't need to change except for in tests; // the size can be drastically reduced for speed. var rsaKeySizeToUse = RSA_2048 - -// CertificateMeta is a container type used to write out a file -// with information about a certificate. -type CertificateMeta struct { - Domain, URL string -} diff --git a/main.go b/main.go index d194aef1..2d4c4a03 100644 --- a/main.go +++ b/main.go @@ -23,6 +23,7 @@ var ( conf string cpu string version bool + revoke string ) func init() { @@ -36,6 +37,7 @@ func init() { flag.BoolVar(&version, "version", false, "Show version") flag.BoolVar(&letsencrypt.Agreed, "agree", false, "Agree to Let's Encrypt Subscriber Agreement") flag.StringVar(&letsencrypt.DefaultEmail, "email", "", "Default email address to use for Let's Encrypt transactions") + flag.StringVar(&revoke, "revoke", "", "Hostname for which to revoke the certificate") } func main() { @@ -45,6 +47,14 @@ func main() { fmt.Printf("%s %s\n", app.Name, app.Version) os.Exit(0) } + if revoke != "" { + err := letsencrypt.Revoke(revoke) + if err != nil { + log.Fatal(err) + } + fmt.Printf("Revoked certificate for %s\n", revoke) + os.Exit(0) + } // Set CPU cap err := app.SetCPU(cpu) From f24ecee603e8950bd089dfd7b3b7c2868fe46b12 Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Wed, 21 Oct 2015 21:28:33 -0600 Subject: [PATCH 32/73] letsencrypt: Basic renewal failover and better error handling --- config/letsencrypt/renew.go | 49 +++++++++++++++++++++++-------------- 1 file changed, 30 insertions(+), 19 deletions(-) diff --git a/config/letsencrypt/renew.go b/config/letsencrypt/renew.go index 4bc992ba..40f376cb 100644 --- a/config/letsencrypt/renew.go +++ b/config/letsencrypt/renew.go @@ -17,8 +17,10 @@ import ( func keepCertificatesRenewed(configs []server.Config) { ticker := time.Tick(renewInterval) for range ticker { - if err := processCertificateRenewal(configs); err != nil { - log.Printf("[ERROR] cert renewal: %v", err) + if errs := processCertificateRenewal(configs); len(errs) > 0 { + for _, err := range errs { + log.Printf("[ERROR] cert renewal: %v\n", err) + } } } } @@ -26,11 +28,12 @@ func keepCertificatesRenewed(configs []server.Config) { // checkCertificateRenewal loops through all configured // sites and looks for certificates to renew. Nothing is mutated // through this function. The changes happen directly on disk. -func processCertificateRenewal(configs []server.Config) error { +func processCertificateRenewal(configs []server.Config) []error { + var errs []error log.Print("[INFO] Processing certificate renewals...") for _, cfg := range configs { - // Check if this entry is TLS enabled and managed by LE + // Host must be TLS-enabled and have assets managed by LE if !cfg.TLS.Enabled || !existingCertAndKey(cfg.Host) { continue } @@ -38,11 +41,13 @@ func processCertificateRenewal(configs []server.Config) error { // Read the certificate and get the NotAfter time. certBytes, err := ioutil.ReadFile(storage.SiteCertFile(cfg.Host)) if err != nil { - return err + errs = append(errs, err) + continue // still have to check other certificates } expTime, err := acme.GetPEMCertExpiration(certBytes) if err != nil { - return err + errs = append(errs, err) + continue } // The time returned from the certificate is always in UTC. @@ -50,23 +55,26 @@ func processCertificateRenewal(configs []server.Config) error { // Directly convert it to days for the following checks. daysLeft := int(expTime.Sub(time.Now().UTC()).Hours() / 24) - // Renew on two or less days remaining. - if daysLeft <= 2 { - log.Printf("[WARN] There are %d days left on the certificate of %s. Trying to renew now.", daysLeft, cfg.Host) + // Renew with a week or less remaining. + if daysLeft <= 7 { + log.Printf("[INFO] There are %d days left on the certificate of %s. Trying to renew now.", daysLeft, cfg.Host) client, err := newClient(getEmail(cfg)) if err != nil { - return err + errs = append(errs, err) + continue } // Read metadata metaBytes, err := ioutil.ReadFile(storage.SiteMetaFile(cfg.Host)) if err != nil { - return err + errs = append(errs, err) + continue } privBytes, err := ioutil.ReadFile(storage.SiteKeyFile(cfg.Host)) if err != nil { - return err + errs = append(errs, err) + continue } var certMeta acme.CertificateResource @@ -78,17 +86,20 @@ func processCertificateRenewal(configs []server.Config) error { // TODO: revokeOld should be an option in the caddyfile newCertMeta, err := client.RenewCertificate(certMeta, true) if err != nil { - return err + time.Sleep(10 * time.Second) + newCertMeta, err = client.RenewCertificate(certMeta, true) + if err != nil { + errs = append(errs, err) + continue + } } saveCertsAndKeys([]acme.CertificateResource{newCertMeta}) - } - - // Warn on 14 days remaining - if daysLeft <= 14 { - log.Printf("[WARN] There are %d days left on the certificate of %s. Will renew on two days left.\n", daysLeft, cfg.Host) + } else if daysLeft <= 14 { + // Warn on 14 days remaining + log.Printf("[WARN] There are %d days left on the certificate for %s. Will renew when 7 days remain.\n", daysLeft, cfg.Host) } } - return nil + return errs } From 2e5eb638503115a32ef1c29b59fd2520214bceac Mon Sep 17 00:00:00 2001 From: xenolf Date: Fri, 23 Oct 2015 16:29:05 +0200 Subject: [PATCH 33/73] Function name changed in lego --- config/letsencrypt/letsencrypt.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/letsencrypt/letsencrypt.go b/config/letsencrypt/letsencrypt.go index 279d23a2..1a3bbab3 100644 --- a/config/letsencrypt/letsencrypt.go +++ b/config/letsencrypt/letsencrypt.go @@ -164,7 +164,7 @@ func newClient(leEmail string) (*acme.Client, error) { leUser.Registration = reg // TODO: we can just do the agreement once: when registering, right? - err = client.AgreeToTos() + err = client.AgreeToTOS() if err != nil { saveUser(leUser) // TODO: Might as well try, right? Error check? return nil, errors.New("error agreeing to terms: " + err.Error()) From f8ad050dda844d3e43ed56cf490b0921dc3acbea Mon Sep 17 00:00:00 2001 From: xenolf Date: Sat, 24 Oct 2015 04:35:55 +0200 Subject: [PATCH 34/73] Update for latest lego changes (cert bundling) --- config/letsencrypt/letsencrypt.go | 2 +- config/letsencrypt/renew.go | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/config/letsencrypt/letsencrypt.go b/config/letsencrypt/letsencrypt.go index 1a3bbab3..876691ae 100644 --- a/config/letsencrypt/letsencrypt.go +++ b/config/letsencrypt/letsencrypt.go @@ -189,7 +189,7 @@ func obtainCertificates(client *acme.Client, serverConfigs []*server.Config) ([] hosts = append(hosts, cfg.Host) } - certificates, err := client.ObtainCertificates(hosts) + certificates, err := client.ObtainCertificates(hosts, true) if err != nil { return nil, errors.New("error obtaining certs: " + err.Error()) } diff --git a/config/letsencrypt/renew.go b/config/letsencrypt/renew.go index 40f376cb..dd80210d 100644 --- a/config/letsencrypt/renew.go +++ b/config/letsencrypt/renew.go @@ -84,10 +84,11 @@ func processCertificateRenewal(configs []server.Config) []error { // Renew certificate. // TODO: revokeOld should be an option in the caddyfile - newCertMeta, err := client.RenewCertificate(certMeta, true) + // TODO: bundle should be an option in the caddyfile as well :) + newCertMeta, err := client.RenewCertificate(certMeta, true, true) if err != nil { time.Sleep(10 * time.Second) - newCertMeta, err = client.RenewCertificate(certMeta, true) + newCertMeta, err = client.RenewCertificate(certMeta, true, true) if err != nil { errs = append(errs, err) continue From 91465d8e6f33af94655abe50be0e38aae9db667c Mon Sep 17 00:00:00 2001 From: xenolf Date: Sat, 24 Oct 2015 04:36:54 +0200 Subject: [PATCH 35/73] Support for OCSP Stapling. Fixes #280 --- config/letsencrypt/letsencrypt.go | 3 +++ server/config.go | 1 + server/server.go | 1 + 3 files changed, 5 insertions(+) diff --git a/config/letsencrypt/letsencrypt.go b/config/letsencrypt/letsencrypt.go index 876691ae..083daa34 100644 --- a/config/letsencrypt/letsencrypt.go +++ b/config/letsencrypt/letsencrypt.go @@ -232,6 +232,9 @@ func saveCertsAndKeys(certificates []acme.CertificateResource) error { // autoConfigure enables TLS on cfg and appends, if necessary, a new config // to allConfigs that redirects plaintext HTTP to its new HTTPS counterpart. func autoConfigure(cfg *server.Config, allConfigs []server.Config) []server.Config { + bundleBytes, _ := ioutil.ReadFile(storage.SiteCertFile(cfg.Host)) + ocsp, _ := acme.GetOCSPForCert(bundleBytes) + cfg.TLS.OCSPStaple = ocsp cfg.TLS.Certificate = storage.SiteCertFile(cfg.Host) cfg.TLS.Key = storage.SiteKeyFile(cfg.Host) cfg.TLS.Enabled = true diff --git a/server/config.go b/server/config.go index dedd7ba3..a3bb5f50 100644 --- a/server/config.go +++ b/server/config.go @@ -56,6 +56,7 @@ type TLSConfig struct { Certificate string Key string LetsEncryptEmail string + OCSPStaple []byte Ciphers []uint16 ProtocolMinVersion uint16 ProtocolMaxVersion uint16 diff --git a/server/server.go b/server/server.go index 24aa92eb..a3c4f92d 100644 --- a/server/server.go +++ b/server/server.go @@ -162,6 +162,7 @@ func ListenAndServeTLSWithSNI(srv *http.Server, tlsConfigs []TLSConfig) error { config.Certificates = make([]tls.Certificate, len(tlsConfigs)) for i, tlsConfig := range tlsConfigs { config.Certificates[i], err = tls.LoadX509KeyPair(tlsConfig.Certificate, tlsConfig.Key) + config.Certificates[i].OCSPStaple = tlsConfig.OCSPStaple if err != nil { return err } From f9f1aafe0c1083e876b5dcaf7e1aa1d291a75830 Mon Sep 17 00:00:00 2001 From: xenolf Date: Mon, 26 Oct 2015 00:53:36 +0100 Subject: [PATCH 36/73] Update to lego update. DevMode no longer exists. --- config/letsencrypt/letsencrypt.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/letsencrypt/letsencrypt.go b/config/letsencrypt/letsencrypt.go index 083daa34..d058d88c 100644 --- a/config/letsencrypt/letsencrypt.go +++ b/config/letsencrypt/letsencrypt.go @@ -152,7 +152,7 @@ func newClient(leEmail string) (*acme.Client, error) { } // The client facilitates our communication with the CA server. - client := acme.NewClient(caURL, &leUser, rsaKeySizeToUse, exposePort, true) // TODO: Dev mode is enabled + client := acme.NewClient(caURL, &leUser, rsaKeySizeToUse, exposePort) // If not registered, the user must register an account with the CA // and agree to terms From b5b31e398ce36c75140987bce1d2094847caa45d Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Sun, 25 Oct 2015 18:45:55 -0600 Subject: [PATCH 37/73] letsencrypt: Graceful restarts Lots of refinement still needed and runs only on POSIX systems. Windows will not get true graceful restarts (for now), but we will opt for very, very quick forceful restarts. Also, server configs are no longer put into a map; it is critical that they stay ordered so that they can be matched with their sockets in the child process after forking. This implementation of graceful restarts is probably not perfect, but it is a good start. Lots of details to attend to now. --- app/app.go | 72 +++++++++ config/config.go | 45 ++++-- main.go | 71 ++++++--- server/graceful.go | 70 +++++++++ server/server.go | 372 +++++++++++++++++++++++++++++++-------------- 5 files changed, 486 insertions(+), 144 deletions(-) create mode 100644 server/graceful.go diff --git a/app/app.go b/app/app.go index c63cc833..3c66e612 100644 --- a/app/app.go +++ b/app/app.go @@ -7,12 +7,15 @@ package app import ( "errors" + "log" "os" + "os/signal" "path/filepath" "runtime" "strconv" "strings" "sync" + "syscall" "github.com/mholt/caddy/server" ) @@ -42,6 +45,75 @@ var ( Quiet bool ) +func init() { + go func() { + // Wait for signal + interrupt := make(chan os.Signal, 1) + signal.Notify(interrupt, os.Interrupt, os.Kill) // TODO: syscall.SIGTERM? Or that should not run callbacks... + <-interrupt + + // Run shutdown callbacks + var exitCode int + ServersMutex.Lock() + errs := server.ShutdownCallbacks(Servers) + ServersMutex.Unlock() + if len(errs) > 0 { + for _, err := range errs { + log.Println(err) + } + exitCode = 1 + } + os.Exit(exitCode) + }() +} + +// Restart restarts the entire application; gracefully with zero +// downtime if on a POSIX-compatible system, or forcefully if on +// Windows but with imperceptibly-short downtime. +// +// The restarted application will use caddyfile as its input +// configuration; it will not look elsewhere for the config +// to use. +func Restart(caddyfile []byte) error { + // TODO: This is POSIX-only right now; also, os.Args[0] is required! + // TODO: Pipe the Caddyfile to stdin of child! + // TODO: Before stopping this process, verify child started successfully (valid Caddyfile, etc) + + // Tell the child that it's a restart + os.Setenv("CADDY_RESTART", "true") + + // Pass along current environment and file descriptors to child. + // We pass along the file descriptors explicitly to ensure proper + // order, since losing the original order will break the child. + fds := []uintptr{os.Stdin.Fd(), os.Stdout.Fd(), os.Stderr.Fd()} + + // Now add file descriptors of the sockets + ServersMutex.Lock() + for _, s := range Servers { + fds = append(fds, s.ListenerFd()) + } + ServersMutex.Unlock() + + // Fork the process with the current environment and file descriptors + execSpec := &syscall.ProcAttr{ + Env: os.Environ(), + Files: fds, + } + fork, err := syscall.ForkExec(os.Args[0], os.Args, execSpec) + if err != nil { + log.Println("FORK ERR:", err, fork) + } + + // Child process is listening now; we can stop all our servers here. + ServersMutex.Lock() + for _, s := range Servers { + go s.Stop() // TODO: error checking/reporting + } + ServersMutex.Unlock() + + return err +} + // SetCPU parses string cpu and sets GOMAXPROCS // according to its value. It accepts either // a number (e.g. 3) or a percent (e.g. 50%). diff --git a/config/config.go b/config/config.go index 97b66ea0..d7fed8bf 100644 --- a/config/config.go +++ b/config/config.go @@ -153,14 +153,14 @@ func makeStorages() map[string]interface{} { // bind address to list of configs that would become VirtualHosts on that // server. Use the keys of the returned map to create listeners, and use // the associated values to set up the virtualhosts. -func arrangeBindings(allConfigs []server.Config) (map[*net.TCPAddr][]server.Config, error) { - addresses := make(map[*net.TCPAddr][]server.Config) +func arrangeBindings(allConfigs []server.Config) (Group, error) { + var groupings Group // Group configs by bind address for _, conf := range allConfigs { - newAddr, warnErr, fatalErr := resolveAddr(conf) + bindAddr, warnErr, fatalErr := resolveAddr(conf) if fatalErr != nil { - return addresses, fatalErr + return groupings, fatalErr } if warnErr != nil { log.Println("[Warning]", warnErr) @@ -169,37 +169,40 @@ func arrangeBindings(allConfigs []server.Config) (map[*net.TCPAddr][]server.Conf // Make sure to compare the string representation of the address, // not the pointer, since a new *TCPAddr is created each time. var existing bool - for addr := range addresses { - if addr.String() == newAddr.String() { - addresses[addr] = append(addresses[addr], conf) + for i := 0; i < len(groupings); i++ { + if groupings[i].BindAddr.String() == bindAddr.String() { + groupings[i].Configs = append(groupings[i].Configs, conf) existing = true break } } if !existing { - addresses[newAddr] = append(addresses[newAddr], conf) + groupings = append(groupings, BindingMapping{ + BindAddr: bindAddr, + Configs: []server.Config{conf}, + }) } } // Don't allow HTTP and HTTPS to be served on the same address - for _, configs := range addresses { - isTLS := configs[0].TLS.Enabled - for _, config := range configs { + for _, group := range groupings { + isTLS := group.Configs[0].TLS.Enabled + for _, config := range group.Configs { if config.TLS.Enabled != isTLS { thisConfigProto, otherConfigProto := "HTTP", "HTTP" if config.TLS.Enabled { thisConfigProto = "HTTPS" } - if configs[0].TLS.Enabled { + if group.Configs[0].TLS.Enabled { otherConfigProto = "HTTPS" } - return addresses, fmt.Errorf("configuration error: Cannot multiplex %s (%s) and %s (%s) on same address", - configs[0].Address(), otherConfigProto, config.Address(), thisConfigProto) + return groupings, fmt.Errorf("configuration error: Cannot multiplex %s (%s) and %s (%s) on same address", + group.Configs[0].Address(), otherConfigProto, config.Address(), thisConfigProto) } } } - return addresses, nil + return groupings, nil } // resolveAddr determines the address (host and port) that a config will @@ -291,5 +294,15 @@ var ( Port = DefaultPort ) +// BindingMapping maps a network address to configurations +// that will bind to it. The order of the configs is important. +type BindingMapping struct { + BindAddr *net.TCPAddr + Configs []server.Config +} + // Group maps network addresses to their configurations. -type Group map[*net.TCPAddr][]server.Config +// Preserving the order of the groupings is important +// (related to graceful shutdown and restart) +// so this is a slice, not a literal map. +type Group []BindingMapping diff --git a/main.go b/main.go index 2d4c4a03..21a3ae50 100644 --- a/main.go +++ b/main.go @@ -6,12 +6,14 @@ import ( "fmt" "io/ioutil" "log" + "net" "os" "os/exec" "path" "runtime" "strconv" "strings" + "time" "github.com/mholt/caddy/app" "github.com/mholt/caddy/config" @@ -63,44 +65,70 @@ func main() { } // Load config from file - addresses, err := loadConfigs() + groupings, err := loadConfigs() if err != nil { log.Fatal(err) } // Start each server with its one or more configurations - for addr, configs := range addresses { - s, err := server.New(addr.String(), configs) + for i, group := range groupings { + s, err := server.New(group.BindAddr.String(), group.Configs) if err != nil { log.Fatal(err) } s.HTTP2 = app.HTTP2 // TODO: This setting is temporary - app.Wg.Add(1) - go func(s *server.Server) { - defer app.Wg.Done() - err := s.Serve() - if err != nil { - log.Fatal(err) // kill whole process to avoid a half-alive zombie server - } - }(s) + app.Wg.Add(1) + go func(s *server.Server, i int) { + defer app.Wg.Done() + + if os.Getenv("CADDY_RESTART") == "true" { + file := os.NewFile(uintptr(3+i), "") + ln, err := net.FileListener(file) + if err != nil { + log.Fatal("FILE LISTENER:", err) + } + + var ok bool + ln, ok = ln.(server.ListenerFile) + if !ok { + log.Fatal("Listener was not a ListenerFile") + } + + err = s.Serve(ln.(server.ListenerFile)) + // TODO: Better error logging... also, is it even necessary? + if err != nil { + log.Println(err) + } + } else { + err := s.ListenAndServe() + // TODO: Better error logging... also, is it even necessary? + // For example, "use of closed network connection" is normal if doing graceful shutdown... + if err != nil { + log.Println(err) + } + } + }(s, i) + + app.ServersMutex.Lock() app.Servers = append(app.Servers, s) + app.ServersMutex.Unlock() } // Show initialization output if !app.Quiet { var checkedFdLimit bool - for addr, configs := range addresses { - for _, conf := range configs { + for _, group := range groupings { + for _, conf := range group.Configs { // Print address of site fmt.Println(conf.Address()) // Note if non-localhost site resolves to loopback interface - if addr.IP.IsLoopback() && !isLocalhost(conf.Host) { + if group.BindAddr.IP.IsLoopback() && !isLocalhost(conf.Host) { fmt.Printf("Notice: %s is only accessible on this machine (%s)\n", - conf.Host, addr.IP.String()) + conf.Host, group.BindAddr.IP.String()) } - if !checkedFdLimit && !addr.IP.IsLoopback() && !isLocalhost(conf.Host) { + if !checkedFdLimit && !group.BindAddr.IP.IsLoopback() && !isLocalhost(conf.Host) { checkFdlimit() checkedFdLimit = true } @@ -108,7 +136,16 @@ func main() { } } - // Wait for all listeners to stop + // TODO: Temporary; testing restart + if os.Getenv("CADDY_RESTART") != "true" { + go func() { + time.Sleep(5 * time.Second) + fmt.Println("restarting") + log.Println("RESTART ERR:", app.Restart([]byte{})) + }() + } + + // Wait for all servers to be stopped app.Wg.Wait() } diff --git a/server/graceful.go b/server/graceful.go new file mode 100644 index 00000000..8f74ec96 --- /dev/null +++ b/server/graceful.go @@ -0,0 +1,70 @@ +package server + +import ( + "net" + "os" + "sync" + "syscall" +) + +// newGracefulListener returns a gracefulListener that wraps l and +// uses wg (stored in the host server) to count connections. +func newGracefulListener(l ListenerFile, wg *sync.WaitGroup) *gracefulListener { + gl := &gracefulListener{ListenerFile: l, stop: make(chan error), httpWg: wg} + go func() { + <-gl.stop + gl.stopped = true + gl.stop <- gl.ListenerFile.Close() + }() + return gl +} + +// gracefuListener is a net.Listener which can +// count the number of connections on it. Its +// methods mainly wrap net.Listener to be graceful. +type gracefulListener struct { + ListenerFile + stop chan error + stopped bool + httpWg *sync.WaitGroup // pointer to the host's wg used for counting connections +} + +// Accept accepts a connection. This type wraps +func (gl *gracefulListener) Accept() (c net.Conn, err error) { + c, err = gl.ListenerFile.Accept() + if err != nil { + return + } + c = gracefulConn{Conn: c, httpWg: gl.httpWg} + gl.httpWg.Add(1) + return +} + +// Close immediately closes the listener. +func (gl *gracefulListener) Close() error { + if gl.stopped { + return syscall.EINVAL + } + gl.stop <- nil + return <-gl.stop +} + +// File implements ListenerFile; it gets the file of the listening socket. +func (gl *gracefulListener) File() (*os.File, error) { + return gl.ListenerFile.File() +} + +// gracefulConn represents a connection on a +// gracefulListener so that we can keep track +// of the number of connections, thus facilitating +// a graceful shutdown. +type gracefulConn struct { + net.Conn + httpWg *sync.WaitGroup // pointer to the host server's connection waitgroup +} + +// Close closes c's underlying connection while updating the wg count. +func (c gracefulConn) Close() error { + c.httpWg.Done() + return c.Conn.Close() +} diff --git a/server/server.go b/server/server.go index 24aa92eb..9ead4621 100644 --- a/server/server.go +++ b/server/server.go @@ -12,18 +12,31 @@ import ( "net" "net/http" "os" - "os/signal" + "runtime" + "sync" + "time" "golang.org/x/net/http2" ) // Server represents an instance of a server, which serves -// static content at a particular address (host and port). +// HTTP requests at a particular address (host and port). A +// server is capable of serving numerous virtual hosts on +// the same address and the listener may be stopped for +// graceful termination (POSIX only). type Server struct { - HTTP2 bool // temporary while http2 is not in std lib (TODO: remove flag when part of std lib) - address string // the actual address for net.Listen to listen on - tls bool // whether this server is serving all HTTPS hosts or not - vhosts map[string]virtualHost // virtual hosts keyed by their address + *http.Server + HTTP2 bool // temporary while http2 is not in std lib (TODO: remove flag when part of std lib) + tls bool // whether this server is serving all HTTPS hosts or not + vhosts map[string]virtualHost // virtual hosts keyed by their address + listener ListenerFile // the listener which is bound to the socket + listenerMu sync.Mutex // protects listener + httpWg sync.WaitGroup // used to wait on outstanding connections +} + +type ListenerFile interface { + net.Listener + File() (*os.File, error) } // New creates a new Server which will bind to addr and serve @@ -36,14 +49,30 @@ func New(addr string, configs []Config) (*Server, error) { } s := &Server{ - address: addr, - tls: tls, - vhosts: make(map[string]virtualHost), + Server: &http.Server{ + Addr: addr, + // TODO: Make these values configurable? + // ReadTimeout: 2 * time.Minute, + // WriteTimeout: 2 * time.Minute, + // MaxHeaderBytes: 1 << 16, + }, + tls: tls, + vhosts: make(map[string]virtualHost), } + s.Handler = s // TODO: this is weird + // We have to bound our wg with one increment + // to prevent a "race condition" that is hard-coded + // into sync.WaitGroup.Wait() - basically, an add + // with a positive delta must be guaranteed to + // occur before Wait() is called on the wg. + fmt.Println("+1 (new)") + s.httpWg.Add(1) + + // Set up each virtualhost for _, conf := range configs { if _, exists := s.vhosts[conf.Host]; exists { - return nil, fmt.Errorf("cannot serve %s - host already defined for address %s", conf.Address(), s.address) + return nil, fmt.Errorf("cannot serve %s - host already defined for address %s", conf.Address(), s.Addr) } vh := virtualHost{config: conf} @@ -60,98 +89,92 @@ func New(addr string, configs []Config) (*Server, error) { return s, nil } -// Serve starts the server. It blocks until the server quits. -func (s *Server) Serve() error { - server := &http.Server{ - Addr: s.address, - Handler: s, +// Serve starts the server with an existing listener. It blocks until the +// server stops. +func (s *Server) Serve(ln ListenerFile) error { + err := s.setup() + if err != nil { + return err + } + return s.serve(ln) +} + +// ListenAndServe starts the server with a new listener. It blocks until the server stops. +func (s *Server) ListenAndServe() error { + err := s.setup() + if err != nil { + return err } - if s.HTTP2 { - // TODO: This call may not be necessary after HTTP/2 is merged into std lib - http2.ConfigureServer(server, nil) + ln, err := net.Listen("tcp", s.Addr) + if err != nil { + return err } - for _, vh := range s.vhosts { - // Execute startup functions now - for _, start := range vh.config.Startup { - err := start() - if err != nil { - return err - } - } + return s.serve(ln.(*net.TCPListener)) +} - // Execute shutdown commands on exit - if len(vh.config.Shutdown) > 0 { - go func(vh virtualHost) { - // Wait for signal - interrupt := make(chan os.Signal, 1) - signal.Notify(interrupt, os.Interrupt, os.Kill) // TODO: syscall.SIGQUIT? (Ctrl+\, Unix-only) - <-interrupt - - // Run callbacks - exitCode := 0 - for _, shutdownFunc := range vh.config.Shutdown { - err := shutdownFunc() - if err != nil { - exitCode = 1 - log.Println(err) - } - } - os.Exit(exitCode) // BUG: Other shutdown goroutines might be running; use sync.WaitGroup - }(vh) - } +// serve prepares s to listen on ln by wrapping ln in a +// tcpKeepAliveListener (if ln is a *net.TCPListener) and +// then in a gracefulListener, so that keep-alive is supported +// as well as graceful shutdown/restart. It also configures +// TLS listener on top of that if applicable. +func (s *Server) serve(ln ListenerFile) error { + if tcpLn, ok := ln.(*net.TCPListener); ok { + ln = tcpKeepAliveListener{TCPListener: tcpLn} } + s.listenerMu.Lock() + s.listener = newGracefulListener(ln, &s.httpWg) + s.listenerMu.Unlock() + if s.tls { var tlsConfigs []TLSConfig for _, vh := range s.vhosts { tlsConfigs = append(tlsConfigs, vh.config.TLS) } - return ListenAndServeTLSWithSNI(server, tlsConfigs) + return serveTLSWithSNI(s, s.listener, tlsConfigs) } - return server.ListenAndServe() + + return s.Server.Serve(s.listener) } -// copy from net/http/transport.go -func cloneTLSConfig(cfg *tls.Config) *tls.Config { - if cfg == nil { - return &tls.Config{} +// setup prepares the server s to begin listening; it should be +// called just before the listener announces itself on the network +// and should only be called when the server is just starting up. +func (s *Server) setup() error { + if s.HTTP2 { + // TODO: This call may not be necessary after HTTP/2 is merged into std lib + http2.ConfigureServer(s.Server, nil) } - return &tls.Config{ - Rand: cfg.Rand, - Time: cfg.Time, - Certificates: cfg.Certificates, - NameToCertificate: cfg.NameToCertificate, - GetCertificate: cfg.GetCertificate, - RootCAs: cfg.RootCAs, - NextProtos: cfg.NextProtos, - ServerName: cfg.ServerName, - ClientAuth: cfg.ClientAuth, - ClientCAs: cfg.ClientCAs, - InsecureSkipVerify: cfg.InsecureSkipVerify, - CipherSuites: cfg.CipherSuites, - PreferServerCipherSuites: cfg.PreferServerCipherSuites, - SessionTicketsDisabled: cfg.SessionTicketsDisabled, - SessionTicketKey: cfg.SessionTicketKey, - ClientSessionCache: cfg.ClientSessionCache, - MinVersion: cfg.MinVersion, - MaxVersion: cfg.MaxVersion, - CurvePreferences: cfg.CurvePreferences, + + // Execute startup functions now + for _, vh := range s.vhosts { + for _, startupFunc := range vh.config.Startup { + err := startupFunc() + if err != nil { + return err + } + } } + + return nil } -// ListenAndServeTLSWithSNI serves TLS with Server Name Indication (SNI) support, which allows -// multiple sites (different hostnames) to be served from the same address. This method is -// adapted directly from the std lib's net/http ListenAndServeTLS function, which was -// written by the Go Authors. It has been modified to support multiple certificate/key pairs. -func ListenAndServeTLSWithSNI(srv *http.Server, tlsConfigs []TLSConfig) error { - addr := srv.Addr +// serveTLSWithSNI serves TLS with Server Name Indication (SNI) support, which allows +// multiple sites (different hostnames) to be served from the same address. It also +// supports client authentication if srv has it enabled. It blocks until s quits. +// +// This method is adapted from the std lib's net/http ServeTLS function, which was written +// by the Go Authors. It has been modified to support multiple certificate/key pairs, +// client authentication, and our custom Server type. +func serveTLSWithSNI(s *Server, ln net.Listener, tlsConfigs []TLSConfig) error { + addr := s.Server.Addr if addr == "" { addr = ":https" } - config := cloneTLSConfig(srv.TLSConfig) + config := cloneTLSConfig(s.TLSConfig) if config.NextProtos == nil { config.NextProtos = []string{"http/1.1"} } @@ -180,45 +203,62 @@ func ListenAndServeTLSWithSNI(srv *http.Server, tlsConfigs []TLSConfig) error { return err } - // Create listener and we're on our way - conn, err := net.Listen("tcp", addr) - if err != nil { - return err - } - tlsListener := tls.NewListener(conn, config) + // Create TLS listener - note that we do not replace s.listener + // with this TLS listener; tls.listener is unexported and does + // not implement the File() method we need for graceful restarts + // on POSIX systems. + ln = tls.NewListener(ln, config) - return srv.Serve(tlsListener) + // Begin serving; block until done + return s.Server.Serve(ln) } -// setupClientAuth sets up TLS client authentication only if -// any of the TLS configs specified at least one cert file. -func setupClientAuth(tlsConfigs []TLSConfig, config *tls.Config) error { - var clientAuth bool - for _, cfg := range tlsConfigs { - if len(cfg.ClientCerts) > 0 { - clientAuth = true - break +// Stop stops the server. It blocks until the server is +// totally stopped. On POSIX systems, it will wait for +// connections to close (up to a max timeout of a few +// seconds); on Windows it will close the listener +// immediately. +func (s *Server) Stop() error { + s.Server.SetKeepAlivesEnabled(false) // TODO: Does this even do anything? :P + + if runtime.GOOS != "windows" { + // force connections to close after timeout + done := make(chan struct{}) + go func() { + s.httpWg.Done() // decrement our initial increment used as a barrier + s.httpWg.Wait() + close(done) + }() + + // Wait for remaining connections to finish or + // force them all to close after timeout + select { + case <-time.After(5 * time.Second): // TODO: configurable? + case <-done: } } - if clientAuth { - pool := x509.NewCertPool() - for _, cfg := range tlsConfigs { - for _, caFile := range cfg.ClientCerts { - caCrt, err := ioutil.ReadFile(caFile) // Anyone that gets a cert from Matt Holt can connect - if err != nil { - return err - } - if !pool.AppendCertsFromPEM(caCrt) { - return fmt.Errorf("error loading client certificate '%s': no certificates were successfully parsed", caFile) - } - } - } - config.ClientCAs = pool - config.ClientAuth = tls.RequireAndVerifyClientCert + // Close the listener now; this stops the server and + s.listenerMu.Lock() + err := s.listener.Close() + s.listenerMu.Unlock() + if err != nil { + // TODO: Better logging + log.Println(err) } - return nil + return err +} + +// ListenerFd gets the file descriptor of the listener. +func (s *Server) ListenerFd() uintptr { + s.listenerMu.Lock() + defer s.listenerMu.Unlock() + file, err := s.listener.File() + if err != nil { + return 0 + } + return file.Fd() } // ServeHTTP is the entry point for every request to the address that s @@ -226,6 +266,9 @@ func setupClientAuth(tlsConfigs []TLSConfig, config *tls.Config) error { // defined in the Host header so that the correct virtualhost // (configuration and middleware stack) will handle the request. func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + fmt.Println("Sleeping") + time.Sleep(5 * time.Second) + fmt.Println("Unblocking") defer func() { // In case the user doesn't enable error middleware, we still // need to make sure that we stay alive up here @@ -260,7 +303,7 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } else { w.WriteHeader(http.StatusNotFound) - fmt.Fprintf(w, "No such host at %s", s.address) + fmt.Fprintf(w, "No such host at %s", s.Server.Addr) } } @@ -270,3 +313,110 @@ func DefaultErrorFunc(w http.ResponseWriter, r *http.Request, status int) { w.WriteHeader(status) fmt.Fprintf(w, "%d %s", status, http.StatusText(status)) } + +// setupClientAuth sets up TLS client authentication only if +// any of the TLS configs specified at least one cert file. +func setupClientAuth(tlsConfigs []TLSConfig, config *tls.Config) error { + var clientAuth bool + for _, cfg := range tlsConfigs { + if len(cfg.ClientCerts) > 0 { + clientAuth = true + break + } + } + + if clientAuth { + pool := x509.NewCertPool() + for _, cfg := range tlsConfigs { + for _, caFile := range cfg.ClientCerts { + caCrt, err := ioutil.ReadFile(caFile) // Anyone that gets a cert from this CA can connect + if err != nil { + return err + } + if !pool.AppendCertsFromPEM(caCrt) { + return fmt.Errorf("error loading client certificate '%s': no certificates were successfully parsed", caFile) + } + } + } + config.ClientCAs = pool + config.ClientAuth = tls.RequireAndVerifyClientCert + } + + return nil +} + +// tcpKeepAliveListener sets TCP keep-alive timeouts on accepted +// connections. It's used by ListenAndServe and ListenAndServeTLS so +// dead TCP connections (e.g. closing laptop mid-download) eventually +// go away. +// +// Borrowed from the Go standard library. +type tcpKeepAliveListener struct { + *net.TCPListener +} + +// Accept accepts the connection with a keep-alive enabled. +func (ln tcpKeepAliveListener) Accept() (c net.Conn, err error) { + tc, err := ln.AcceptTCP() + if err != nil { + return + } + tc.SetKeepAlive(true) + tc.SetKeepAlivePeriod(3 * time.Minute) + return tc, nil +} + +// File implements ListenerFile; returns the underlying file of the listener. +func (ln tcpKeepAliveListener) File() (*os.File, error) { + return ln.TCPListener.File() +} + +// copied from net/http/transport.go +func cloneTLSConfig(cfg *tls.Config) *tls.Config { + if cfg == nil { + return &tls.Config{} + } + return &tls.Config{ + Rand: cfg.Rand, + Time: cfg.Time, + Certificates: cfg.Certificates, + NameToCertificate: cfg.NameToCertificate, + GetCertificate: cfg.GetCertificate, + RootCAs: cfg.RootCAs, + NextProtos: cfg.NextProtos, + ServerName: cfg.ServerName, + ClientAuth: cfg.ClientAuth, + ClientCAs: cfg.ClientCAs, + InsecureSkipVerify: cfg.InsecureSkipVerify, + CipherSuites: cfg.CipherSuites, + PreferServerCipherSuites: cfg.PreferServerCipherSuites, + SessionTicketsDisabled: cfg.SessionTicketsDisabled, + SessionTicketKey: cfg.SessionTicketKey, + ClientSessionCache: cfg.ClientSessionCache, + MinVersion: cfg.MinVersion, + MaxVersion: cfg.MaxVersion, + CurvePreferences: cfg.CurvePreferences, + } +} + +// ShutdownCallbacks executes all the shutdown callbacks +// for all the virtualhosts in servers, and returns all the +// errors generated during their execution. In other words, +// an error executing one shutdown callback does not stop +// execution of others. Only one shutdown callback is executed +// at a time. You must protect the servers that are passed in +// if they are shared across threads. +func ShutdownCallbacks(servers []*Server) []error { + var errs []error + for _, s := range servers { + for _, vhost := range s.vhosts { + for _, shutdownFunc := range vhost.config.Shutdown { + err := shutdownFunc() + if err != nil { + errs = append(errs, err) + } + } + } + } + return errs +} From 69366580192c127dd4f24ad6b3676cd55274292e Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Sun, 25 Oct 2015 19:30:29 -0600 Subject: [PATCH 38/73] letsencrypt: Work with latest lego changes --- config/letsencrypt/letsencrypt.go | 6 +++--- config/letsencrypt/renew.go | 4 ++-- main.go | 7 +++---- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/config/letsencrypt/letsencrypt.go b/config/letsencrypt/letsencrypt.go index 279d23a2..632e8000 100644 --- a/config/letsencrypt/letsencrypt.go +++ b/config/letsencrypt/letsencrypt.go @@ -152,7 +152,7 @@ func newClient(leEmail string) (*acme.Client, error) { } // The client facilitates our communication with the CA server. - client := acme.NewClient(caURL, &leUser, rsaKeySizeToUse, exposePort, true) // TODO: Dev mode is enabled + client := acme.NewClient(caURL, &leUser, rsaKeySizeToUse, exposePort) // If not registered, the user must register an account with the CA // and agree to terms @@ -164,7 +164,7 @@ func newClient(leEmail string) (*acme.Client, error) { leUser.Registration = reg // TODO: we can just do the agreement once: when registering, right? - err = client.AgreeToTos() + err = client.AgreeToTOS() if err != nil { saveUser(leUser) // TODO: Might as well try, right? Error check? return nil, errors.New("error agreeing to terms: " + err.Error()) @@ -189,7 +189,7 @@ func obtainCertificates(client *acme.Client, serverConfigs []*server.Config) ([] hosts = append(hosts, cfg.Host) } - certificates, err := client.ObtainCertificates(hosts) + certificates, err := client.ObtainCertificates(hosts, true) if err != nil { return nil, errors.New("error obtaining certs: " + err.Error()) } diff --git a/config/letsencrypt/renew.go b/config/letsencrypt/renew.go index 40f376cb..291df06c 100644 --- a/config/letsencrypt/renew.go +++ b/config/letsencrypt/renew.go @@ -84,10 +84,10 @@ func processCertificateRenewal(configs []server.Config) []error { // Renew certificate. // TODO: revokeOld should be an option in the caddyfile - newCertMeta, err := client.RenewCertificate(certMeta, true) + newCertMeta, err := client.RenewCertificate(certMeta, true, true) if err != nil { time.Sleep(10 * time.Second) - newCertMeta, err = client.RenewCertificate(certMeta, true) + newCertMeta, err = client.RenewCertificate(certMeta, true, true) if err != nil { errs = append(errs, err) continue diff --git a/main.go b/main.go index 21a3ae50..b080d663 100644 --- a/main.go +++ b/main.go @@ -89,13 +89,12 @@ func main() { log.Fatal("FILE LISTENER:", err) } - var ok bool - ln, ok = ln.(server.ListenerFile) + lnf, ok := ln.(server.ListenerFile) if !ok { log.Fatal("Listener was not a ListenerFile") } - err = s.Serve(ln.(server.ListenerFile)) + err = s.Serve(lnf) // TODO: Better error logging... also, is it even necessary? if err != nil { log.Println(err) @@ -204,7 +203,7 @@ func loadConfigs() (config.Group, error) { } } - // Command line Arg + // Command line args if flag.NArg() > 0 { confBody := ":" + config.DefaultPort + "\n" + strings.Join(flag.Args(), "\n") return config.Load("args", bytes.NewBufferString(confBody)) From 4ebff9a13065757988dcec314a9989e9b48a0ed7 Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Mon, 26 Oct 2015 13:34:31 -0600 Subject: [PATCH 39/73] core: Major refactor for graceful restarts; numerous fixes Merged config and app packages into one called caddy. Abstracted away caddy startup functionality making it easier to embed Caddy in any Go application and use it as a library. Graceful restart (should) now ensure child starts properly. Now piping a gob bundle to child process so that the child can match up inherited listeners to server address. Much cleanup still to do. --- app/app.go | 172 ------- caddy/assets/path.go | 29 ++ caddy/caddy.go | 470 ++++++++++++++++++ {config => caddy}/config.go | 17 +- {config => caddy}/config_test.go | 2 +- {config => caddy}/directives.go | 6 +- {config => caddy}/letsencrypt/crypto.go | 0 {config => caddy}/letsencrypt/crypto_test.go | 0 {config => caddy}/letsencrypt/letsencrypt.go | 6 + .../letsencrypt/letsencrypt_test.go | 0 {config => caddy}/letsencrypt/renew.go | 17 +- {config => caddy}/letsencrypt/storage.go | 4 +- {config => caddy}/letsencrypt/storage_test.go | 0 {config => caddy}/letsencrypt/user.go | 0 {config => caddy}/letsencrypt/user_test.go | 0 {config => caddy}/parse/dispenser.go | 0 {config => caddy}/parse/dispenser_test.go | 0 {config => caddy}/parse/import_test1.txt | 0 {config => caddy}/parse/import_test2.txt | 0 {config => caddy}/parse/lexer.go | 0 {config => caddy}/parse/lexer_test.go | 0 {config => caddy}/parse/parse.go | 0 {config => caddy}/parse/parse_test.go | 0 {config => caddy}/parse/parsing.go | 0 {config => caddy}/parse/parsing_test.go | 0 {config => caddy}/setup/basicauth.go | 0 {config => caddy}/setup/basicauth_test.go | 0 {config => caddy}/setup/bindhost.go | 0 {config => caddy}/setup/browse.go | 0 {config => caddy}/setup/controller.go | 2 +- {config => caddy}/setup/errors.go | 0 {config => caddy}/setup/errors_test.go | 0 {config => caddy}/setup/ext.go | 0 {config => caddy}/setup/ext_test.go | 0 {config => caddy}/setup/fastcgi.go | 0 {config => caddy}/setup/fastcgi_test.go | 0 {config => caddy}/setup/gzip.go | 0 {config => caddy}/setup/gzip_test.go | 0 {config => caddy}/setup/headers.go | 0 {config => caddy}/setup/headers_test.go | 0 {config => caddy}/setup/internal.go | 0 {config => caddy}/setup/internal_test.go | 0 {config => caddy}/setup/log.go | 0 {config => caddy}/setup/log_test.go | 0 {config => caddy}/setup/markdown.go | 0 {config => caddy}/setup/markdown_test.go | 0 {config => caddy}/setup/mime.go | 0 {config => caddy}/setup/mime_test.go | 0 {config => caddy}/setup/proxy.go | 0 {config => caddy}/setup/redir.go | 0 {config => caddy}/setup/rewrite.go | 0 {config => caddy}/setup/rewrite_test.go | 0 {config => caddy}/setup/roller.go | 0 {config => caddy}/setup/root.go | 0 {config => caddy}/setup/root_test.go | 0 {config => caddy}/setup/startupshutdown.go | 0 {config => caddy}/setup/templates.go | 0 {config => caddy}/setup/templates_test.go | 0 .../setup/testdata/blog/first_post.md | 0 {config => caddy}/setup/testdata/header.html | 0 .../setup/testdata/tpl_with_include.html | 0 {config => caddy}/setup/tls.go | 0 {config => caddy}/setup/tls_test.go | 0 {config => caddy}/setup/websocket.go | 0 {config => caddy}/setup/websocket_test.go | 0 main.go | 237 ++++----- middleware/proxy/upstream.go | 2 +- server/graceful.go | 8 +- server/server.go | 10 +- 69 files changed, 630 insertions(+), 352 deletions(-) delete mode 100644 app/app.go create mode 100644 caddy/assets/path.go create mode 100644 caddy/caddy.go rename {config => caddy}/config.go (96%) rename {config => caddy}/config_test.go (99%) rename {config => caddy}/directives.go (96%) rename {config => caddy}/letsencrypt/crypto.go (100%) rename {config => caddy}/letsencrypt/crypto_test.go (100%) rename {config => caddy}/letsencrypt/letsencrypt.go (97%) rename {config => caddy}/letsencrypt/letsencrypt_test.go (100%) rename {config => caddy}/letsencrypt/renew.go (88%) rename {config => caddy}/letsencrypt/storage.go (95%) rename {config => caddy}/letsencrypt/storage_test.go (100%) rename {config => caddy}/letsencrypt/user.go (100%) rename {config => caddy}/letsencrypt/user_test.go (100%) rename {config => caddy}/parse/dispenser.go (100%) rename {config => caddy}/parse/dispenser_test.go (100%) rename {config => caddy}/parse/import_test1.txt (100%) rename {config => caddy}/parse/import_test2.txt (100%) rename {config => caddy}/parse/lexer.go (100%) rename {config => caddy}/parse/lexer_test.go (100%) rename {config => caddy}/parse/parse.go (100%) rename {config => caddy}/parse/parse_test.go (100%) rename {config => caddy}/parse/parsing.go (100%) rename {config => caddy}/parse/parsing_test.go (100%) rename {config => caddy}/setup/basicauth.go (100%) rename {config => caddy}/setup/basicauth_test.go (100%) rename {config => caddy}/setup/bindhost.go (100%) rename {config => caddy}/setup/browse.go (100%) rename {config => caddy}/setup/controller.go (98%) rename {config => caddy}/setup/errors.go (100%) rename {config => caddy}/setup/errors_test.go (100%) rename {config => caddy}/setup/ext.go (100%) rename {config => caddy}/setup/ext_test.go (100%) rename {config => caddy}/setup/fastcgi.go (100%) rename {config => caddy}/setup/fastcgi_test.go (100%) rename {config => caddy}/setup/gzip.go (100%) rename {config => caddy}/setup/gzip_test.go (100%) rename {config => caddy}/setup/headers.go (100%) rename {config => caddy}/setup/headers_test.go (100%) rename {config => caddy}/setup/internal.go (100%) rename {config => caddy}/setup/internal_test.go (100%) rename {config => caddy}/setup/log.go (100%) rename {config => caddy}/setup/log_test.go (100%) rename {config => caddy}/setup/markdown.go (100%) rename {config => caddy}/setup/markdown_test.go (100%) rename {config => caddy}/setup/mime.go (100%) rename {config => caddy}/setup/mime_test.go (100%) rename {config => caddy}/setup/proxy.go (100%) rename {config => caddy}/setup/redir.go (100%) rename {config => caddy}/setup/rewrite.go (100%) rename {config => caddy}/setup/rewrite_test.go (100%) rename {config => caddy}/setup/roller.go (100%) rename {config => caddy}/setup/root.go (100%) rename {config => caddy}/setup/root_test.go (100%) rename {config => caddy}/setup/startupshutdown.go (100%) rename {config => caddy}/setup/templates.go (100%) rename {config => caddy}/setup/templates_test.go (100%) rename {config => caddy}/setup/testdata/blog/first_post.md (100%) rename {config => caddy}/setup/testdata/header.html (100%) rename {config => caddy}/setup/testdata/tpl_with_include.html (100%) rename {config => caddy}/setup/tls.go (100%) rename {config => caddy}/setup/tls_test.go (100%) rename {config => caddy}/setup/websocket.go (100%) rename {config => caddy}/setup/websocket_test.go (100%) diff --git a/app/app.go b/app/app.go deleted file mode 100644 index 3c66e612..00000000 --- a/app/app.go +++ /dev/null @@ -1,172 +0,0 @@ -// Package app holds application-global state to make it accessible -// by other packages in the application. -// -// This package differs from config in that the things in app aren't -// really related to server configuration. -package app - -import ( - "errors" - "log" - "os" - "os/signal" - "path/filepath" - "runtime" - "strconv" - "strings" - "sync" - "syscall" - - "github.com/mholt/caddy/server" -) - -const ( - // Name is the program name - Name = "Caddy" - - // Version is the program version - Version = "0.7.6" -) - -var ( - // Servers is a list of all the currently-listening servers - Servers []*server.Server - - // ServersMutex protects the Servers slice during changes - ServersMutex sync.Mutex - - // Wg is used to wait for all servers to shut down - Wg sync.WaitGroup - - // HTTP2 indicates whether HTTP2 is enabled or not - HTTP2 bool // TODO: temporary flag until http2 is standard - - // Quiet mode hides non-error initialization output - Quiet bool -) - -func init() { - go func() { - // Wait for signal - interrupt := make(chan os.Signal, 1) - signal.Notify(interrupt, os.Interrupt, os.Kill) // TODO: syscall.SIGTERM? Or that should not run callbacks... - <-interrupt - - // Run shutdown callbacks - var exitCode int - ServersMutex.Lock() - errs := server.ShutdownCallbacks(Servers) - ServersMutex.Unlock() - if len(errs) > 0 { - for _, err := range errs { - log.Println(err) - } - exitCode = 1 - } - os.Exit(exitCode) - }() -} - -// Restart restarts the entire application; gracefully with zero -// downtime if on a POSIX-compatible system, or forcefully if on -// Windows but with imperceptibly-short downtime. -// -// The restarted application will use caddyfile as its input -// configuration; it will not look elsewhere for the config -// to use. -func Restart(caddyfile []byte) error { - // TODO: This is POSIX-only right now; also, os.Args[0] is required! - // TODO: Pipe the Caddyfile to stdin of child! - // TODO: Before stopping this process, verify child started successfully (valid Caddyfile, etc) - - // Tell the child that it's a restart - os.Setenv("CADDY_RESTART", "true") - - // Pass along current environment and file descriptors to child. - // We pass along the file descriptors explicitly to ensure proper - // order, since losing the original order will break the child. - fds := []uintptr{os.Stdin.Fd(), os.Stdout.Fd(), os.Stderr.Fd()} - - // Now add file descriptors of the sockets - ServersMutex.Lock() - for _, s := range Servers { - fds = append(fds, s.ListenerFd()) - } - ServersMutex.Unlock() - - // Fork the process with the current environment and file descriptors - execSpec := &syscall.ProcAttr{ - Env: os.Environ(), - Files: fds, - } - fork, err := syscall.ForkExec(os.Args[0], os.Args, execSpec) - if err != nil { - log.Println("FORK ERR:", err, fork) - } - - // Child process is listening now; we can stop all our servers here. - ServersMutex.Lock() - for _, s := range Servers { - go s.Stop() // TODO: error checking/reporting - } - ServersMutex.Unlock() - - return err -} - -// SetCPU parses string cpu and sets GOMAXPROCS -// according to its value. It accepts either -// a number (e.g. 3) or a percent (e.g. 50%). -func SetCPU(cpu string) error { - var numCPU int - - availCPU := runtime.NumCPU() - - if strings.HasSuffix(cpu, "%") { - // Percent - var percent float32 - pctStr := cpu[:len(cpu)-1] - pctInt, err := strconv.Atoi(pctStr) - if err != nil || pctInt < 1 || pctInt > 100 { - return errors.New("invalid CPU value: percentage must be between 1-100") - } - percent = float32(pctInt) / 100 - numCPU = int(float32(availCPU) * percent) - } else { - // Number - num, err := strconv.Atoi(cpu) - if err != nil || num < 1 { - return errors.New("invalid CPU value: provide a number or percent greater than 0") - } - numCPU = num - } - - if numCPU > availCPU { - numCPU = availCPU - } - - runtime.GOMAXPROCS(numCPU) - return nil -} - -// DataFolder returns the path to the folder -// where the application may store data. This -// currently resolves to ~/.caddy -func DataFolder() string { - return filepath.Join(userHomeDir(), ".caddy") -} - -// userHomeDir returns the user's home directory according to -// environment variables. -// -// Credit: http://stackoverflow.com/a/7922977/1048862 -func userHomeDir() string { - if runtime.GOOS == "windows" { - home := os.Getenv("HOMEDRIVE") + os.Getenv("HOMEPATH") - if home == "" { - home = os.Getenv("USERPROFILE") - } - return home - } - return os.Getenv("HOME") -} diff --git a/caddy/assets/path.go b/caddy/assets/path.go new file mode 100644 index 00000000..46b883b1 --- /dev/null +++ b/caddy/assets/path.go @@ -0,0 +1,29 @@ +package assets + +import ( + "os" + "path/filepath" + "runtime" +) + +// Path returns the path to the folder +// where the application may store data. This +// currently resolves to ~/.caddy +func Path() string { + return filepath.Join(userHomeDir(), ".caddy") +} + +// userHomeDir returns the user's home directory according to +// environment variables. +// +// Credit: http://stackoverflow.com/a/7922977/1048862 +func userHomeDir() string { + if runtime.GOOS == "windows" { + home := os.Getenv("HOMEDRIVE") + os.Getenv("HOMEPATH") + if home == "" { + home = os.Getenv("USERPROFILE") + } + return home + } + return os.Getenv("HOME") +} diff --git a/caddy/caddy.go b/caddy/caddy.go new file mode 100644 index 00000000..1cc039b6 --- /dev/null +++ b/caddy/caddy.go @@ -0,0 +1,470 @@ +package caddy + +import ( + "bytes" + "encoding/gob" + "errors" + "fmt" + "io/ioutil" + "log" + "net" + "os" + "os/exec" + "os/signal" + "path" + "runtime" + "strconv" + "strings" + "sync" + "syscall" + + "github.com/mholt/caddy/caddy/letsencrypt" + "github.com/mholt/caddy/server" +) + +// Configurable application parameters +var ( + // The name and version of the application. + AppName, AppVersion string + + // If true, initialization will not show any output. + Quiet bool + + // DefaultInput is the default configuration to use when config input is empty or missing. + DefaultInput = CaddyfileInput{ + Contents: []byte(fmt.Sprintf("%s:%s\nroot %s", DefaultHost, DefaultPort, DefaultRoot)), + } + + // HTTP2 indicates whether HTTP2 is enabled or not + HTTP2 bool // TODO: temporary flag until http2 is standard +) + +var ( + // caddyfile is the input configuration text used for this process + caddyfile Input + + // caddyfileMu protects caddyfile during changes + caddyfileMu sync.Mutex + + // incompleteRestartErr occurs if this process is a fork + // of the parent but no Caddyfile was piped in + incompleteRestartErr = errors.New("cannot finish restart successfully") + + // servers is a list of all the currently-listening servers + servers []*server.Server + + // serversMu protects the servers slice during changes + serversMu sync.Mutex + + // wg is used to wait for all servers to shut down + wg sync.WaitGroup + + // loadedGob is used if this is a child process as part of + // a graceful restart; it is used to map listeners to their + // index in the list of inherited file descriptors. This + // variable is not safe for concurrent access. + loadedGob caddyfileGob +) + +const ( + DefaultHost = "0.0.0.0" + DefaultPort = "2015" + DefaultRoot = "." +) + +// caddyfileGob maps bind address to index of the file descriptor +// in the Files array passed to the child process. It also contains +// the caddyfile contents. +type caddyfileGob struct { + ListenerFds map[string]uintptr + Caddyfile []byte +} + +// Start starts Caddy with the given Caddyfile. If cdyfile +// is nil or the process is forked from a parent as part of +// a graceful restart, Caddy will check to see if Caddyfile +// was piped from stdin and use that. +// +// If this process is a fork and no Caddyfile was piped in, +// an error will be returned. If this process is NOT a fork +// and cdyfile is nil, a default configuration will be assumed. +// In any case, an error is returned if Caddy could not be +// started. +func Start(cdyfile Input) error { + var err error + + // Input must never be nil; try to load something + if cdyfile == nil { + cdyfile, err = LoadCaddyfile(nil) + if err != nil { + return err + } + } + + caddyfileMu.Lock() + caddyfile = cdyfile + caddyfileMu.Unlock() + + groupings, err := Load(path.Base(caddyfile.Path()), bytes.NewReader(caddyfile.Body())) + + // Start each server with its one or more configurations + for i, group := range groupings { + s, err := server.New(group.BindAddr.String(), group.Configs) + if err != nil { + log.Fatal(err) + } + s.HTTP2 = HTTP2 // TODO: This setting is temporary + + var ln server.ListenerFile + if isRestart() { + // Look up this server's listener in the map of inherited file descriptors; + // if we don't have one, we must make a new one. + if fdIndex, ok := loadedGob.ListenerFds[s.Addr]; ok { + file := os.NewFile(fdIndex, "") + + fln, err := net.FileListener(file) + if err != nil { + log.Fatal("FILE LISTENER:", err) + } + + ln, ok = fln.(server.ListenerFile) + if !ok { + log.Fatal("Listener was not a ListenerFile") + } + + delete(loadedGob.ListenerFds, s.Addr) // mark it as used + } + } + + wg.Add(1) + go func(s *server.Server, i int, ln server.ListenerFile) { + defer wg.Done() + if ln == nil { + err := s.ListenAndServe() + // "use of closed network connection" is normal if doing graceful shutdown... + if !strings.Contains(err.Error(), "use of closed network connection") { + // But an error at initial startup must be fatal + log.Fatal(err) + } + } else { + err := s.Serve(ln) + if err != nil { + log.Println(err) + } + } + }(s, i, ln) + + serversMu.Lock() + servers = append(servers, s) + serversMu.Unlock() + } + + // Close remaining file descriptors we may have inherited that we don't need + if isRestart() { + for _, fdIndex := range loadedGob.ListenerFds { + file := os.NewFile(fdIndex, "") + fln, err := net.FileListener(file) + if err == nil { + fln.Close() + } + } + } + + // Show initialization output + if !Quiet && !isRestart() { + var checkedFdLimit bool + for _, group := range groupings { + for _, conf := range group.Configs { + // Print address of site + fmt.Println(conf.Address()) + + // Note if non-localhost site resolves to loopback interface + if group.BindAddr.IP.IsLoopback() && !isLocalhost(conf.Host) { + fmt.Printf("Notice: %s is only accessible on this machine (%s)\n", + conf.Host, group.BindAddr.IP.String()) + } + if !checkedFdLimit && !group.BindAddr.IP.IsLoopback() && !isLocalhost(conf.Host) { + checkFdlimit() + checkedFdLimit = true + } + } + } + } + + // Tell parent we're A-OK + if isRestart() { + file := os.NewFile(3, "") + file.Write([]byte("success")) + file.Close() + } + + return nil +} + +// isLocalhost returns true if the string looks explicitly like a localhost address. +func isLocalhost(s string) bool { + return s == "localhost" || s == "::1" || strings.HasPrefix(s, "127.") +} + +// checkFdlimit issues a warning if the OS max file descriptors is below a recommended minimum. +func checkFdlimit() { + const min = 4096 + + // Warn if ulimit is too low for production sites + if runtime.GOOS == "linux" || runtime.GOOS == "darwin" { + out, err := exec.Command("sh", "-c", "ulimit -n").Output() // use sh because ulimit isn't in Linux $PATH + if err == nil { + // Note that an error here need not be reported + lim, err := strconv.Atoi(string(bytes.TrimSpace(out))) + if err == nil && lim < min { + fmt.Printf("Warning: File descriptor limit %d is too low for production sites. At least %d is recommended. Set with \"ulimit -n %d\".\n", lim, min, min) + } + } + } +} + +func Stop() error { + serversMu.Lock() + for _, s := range servers { + s.Stop() // TODO: error checking/reporting? + } + serversMu.Unlock() + return nil +} + +// Restart restarts the entire application; gracefully with zero +// downtime if on a POSIX-compatible system, or forcefully if on +// Windows but with imperceptibly-short downtime. +// +// The restarted application will use newCaddyfile as its input +// configuration. If newCaddyfile is nil, the current (existing) +// Caddyfile configuration will be used. +func Restart(newCaddyfile Input) error { + if newCaddyfile == nil { + caddyfileMu.Lock() + newCaddyfile = caddyfile + caddyfileMu.Unlock() + } + + if runtime.GOOS == "windows" { + err := Stop() + if err != nil { + return err + } + err = Start(newCaddyfile) + if err != nil { + return err + } + return nil + } + + if len(os.Args) == 0 { // this should never happen, but just in case... + os.Args = []string{""} + } + + // Tell the child that it's a restart + os.Setenv("CADDY_RESTART", "true") + + // Prepare our payload to the child process + cdyfileGob := caddyfileGob{ + ListenerFds: make(map[string]uintptr), + Caddyfile: newCaddyfile.Body(), + } + + // Prepare a pipe to the fork's stdin so it can get the Caddyfile + rpipe, wpipe, err := os.Pipe() + if err != nil { + return err + } + + // Prepare a pipe that the child process will use to communicate + // its success or failure with us, the parent + sigrpipe, sigwpipe, err := os.Pipe() + if err != nil { + return err + } + + // Pass along current environment and file descriptors to child. + // Ordering here is very important: stdin, stdout, stderr, sigpipe, + // and then the listener file descriptors (in order). + fds := []uintptr{rpipe.Fd(), os.Stdout.Fd(), os.Stderr.Fd(), sigwpipe.Fd()} + + // Now add file descriptors of the sockets + serversMu.Lock() + for i, s := range servers { + fds = append(fds, s.ListenerFd()) + cdyfileGob.ListenerFds[s.Addr] = uintptr(4 + i) // 4 fds come before any of the listeners + } + serversMu.Unlock() + + // Fork the process with the current environment and file descriptors + execSpec := &syscall.ProcAttr{ + Env: os.Environ(), + Files: fds, + } + pid, err := syscall.ForkExec(os.Args[0], os.Args, execSpec) + if err != nil { + log.Println("FORK ERR:", err, pid) + } + + // Feed it the Caddyfile + err = gob.NewEncoder(wpipe).Encode(cdyfileGob) + if err != nil { + return err + } + wpipe.Close() + + // Wait for child process to signal success or fail + sigwpipe.Close() // close our copy of the write end of the pipe + answer, err := ioutil.ReadAll(sigrpipe) + if err != nil || len(answer) == 0 { + log.Println("restart: child failed to answer; changes not applied") + return incompleteRestartErr + } + + // Child process is listening now; we can stop all our servers here. + return Stop() +} + +// Wait blocks until all servers are stopped. +func Wait() { + wg.Wait() +} + +// LoadCaddyfile loads a Caddyfile in a way that prioritizes +// reading from stdin pipe; otherwise it calls loader to load +// the Caddyfile. If loader does not return a Caddyfile, the +// default one will be returned. Thus, if there are no other +// errors, this function always returns at least the default +// Caddyfile. +func LoadCaddyfile(loader func() (Input, error)) (cdyfile Input, err error) { + // If we are a fork, finishing the restart is highest priority; + // piped input is required in this case. + if isRestart() { + err := gob.NewDecoder(os.Stdin).Decode(&loadedGob) + if err != nil { + return nil, err + } + cdyfile = CaddyfileInput{ + Filepath: os.Stdin.Name(), + Contents: loadedGob.Caddyfile, + } + } + + // Otherwise, we first try to get from stdin pipe + if cdyfile == nil { + cdyfile, err = CaddyfileFromPipe(os.Stdin) + if err != nil { + return nil, err + } + } + + // No piped input, so try the user's loader instead + if cdyfile == nil && loader != nil { + cdyfile, err = loader() + } + + // Otherwise revert to default + if cdyfile == nil { + cdyfile = DefaultInput + } + + return +} + +// Caddyfile returns the current Caddyfile +func Caddyfile() Input { + caddyfileMu.Lock() + defer caddyfileMu.Unlock() + return caddyfile +} + +// isRestart returns whether this process is, according +// to env variables, a fork as part of a graceful restart. +func isRestart() bool { + return os.Getenv("CADDY_RESTART") == "true" +} + +// CaddyfileFromPipe loads the Caddyfile input from f if f is +// not interactive input. f is assumed to be a pipe or stream, +// such as os.Stdin. If f is not a pipe, no error is returned +// but the Input value will be nil. An error is only returned +// if there was an error reading the pipe, even if the length +// of what was read is 0. +func CaddyfileFromPipe(f *os.File) (Input, error) { + fi, err := f.Stat() + if err == nil && fi.Mode()&os.ModeCharDevice == 0 { + // Note that a non-nil error is not a problem. Windows + // will not create a stdin if there is no pipe, which + // produces an error when calling Stat(). But Unix will + // make one either way, which is why we also check that + // bitmask. + // BUG: Reading from stdin after this fails (e.g. for the let's encrypt email address) (OS X) + confBody, err := ioutil.ReadAll(f) + if err != nil { + return nil, err + } + return CaddyfileInput{ + Contents: confBody, + Filepath: f.Name(), + }, nil + } + + // not having input from the pipe is not itself an error, + // just means no input to return. + return nil, nil +} + +// Input represents a Caddyfile; its contents and file path +// (which should include the file name at the end of the path). +// If path does not apply (e.g. piped input) you may use +// any understandable value. The path is mainly used for logging, +// error messages, and debugging. +type Input interface { + // Gets the Caddyfile contents + Body() []byte + + // Gets the path to the origin file + Path() string +} + +// CaddyfileInput represents a Caddyfile as input +// and is simply a convenient way to implement +// the Input interface. +type CaddyfileInput struct { + Filepath string + Contents []byte +} + +// Body returns c.Contents. +func (c CaddyfileInput) Body() []byte { return c.Contents } + +// Path returns c.Filepath. +func (c CaddyfileInput) Path() string { return c.Filepath } + +func init() { + letsencrypt.OnRenew = func() error { return Restart(nil) } + + // Trap signals + go func() { + // Wait for signal + interrupt := make(chan os.Signal, 1) + signal.Notify(interrupt, os.Interrupt, os.Kill) + <-interrupt + + // TODO: A signal just for graceful restart (reload config) - maybe SIGUSR1 + + // Run shutdown callbacks + var exitCode int + serversMu.Lock() + errs := server.ShutdownCallbacks(servers) + serversMu.Unlock() + if len(errs) > 0 { + for _, err := range errs { + log.Println(err) + } + exitCode = 1 + } + os.Exit(exitCode) + }() +} diff --git a/config/config.go b/caddy/config.go similarity index 96% rename from config/config.go rename to caddy/config.go index d7fed8bf..dac65784 100644 --- a/config/config.go +++ b/caddy/config.go @@ -1,4 +1,4 @@ -package config +package caddy import ( "fmt" @@ -7,19 +7,14 @@ import ( "net" "sync" - "github.com/mholt/caddy/app" - "github.com/mholt/caddy/config/letsencrypt" - "github.com/mholt/caddy/config/parse" - "github.com/mholt/caddy/config/setup" + "github.com/mholt/caddy/caddy/letsencrypt" + "github.com/mholt/caddy/caddy/parse" + "github.com/mholt/caddy/caddy/setup" "github.com/mholt/caddy/middleware" "github.com/mholt/caddy/server" ) const ( - DefaultHost = "0.0.0.0" - DefaultPort = "2015" - DefaultRoot = "." - // DefaultConfigFile is the name of the configuration file that is loaded // by default if no other file is specified. DefaultConfigFile = "Caddyfile" @@ -56,8 +51,8 @@ func Load(filename string, input io.Reader) (Group, error) { Root: Root, Middleware: make(map[string][]middleware.Middleware), ConfigFile: filename, - AppName: app.Name, - AppVersion: app.Version, + AppName: AppName, + AppVersion: AppVersion, } // It is crucial that directives are executed in the proper order. diff --git a/config/config_test.go b/caddy/config_test.go similarity index 99% rename from config/config_test.go rename to caddy/config_test.go index 75678419..477da207 100644 --- a/config/config_test.go +++ b/caddy/config_test.go @@ -1,4 +1,4 @@ -package config +package caddy import ( "testing" diff --git a/config/directives.go b/caddy/directives.go similarity index 96% rename from config/directives.go rename to caddy/directives.go index 354b5595..3ebee795 100644 --- a/config/directives.go +++ b/caddy/directives.go @@ -1,8 +1,8 @@ -package config +package caddy import ( - "github.com/mholt/caddy/config/parse" - "github.com/mholt/caddy/config/setup" + "github.com/mholt/caddy/caddy/parse" + "github.com/mholt/caddy/caddy/setup" "github.com/mholt/caddy/middleware" ) diff --git a/config/letsencrypt/crypto.go b/caddy/letsencrypt/crypto.go similarity index 100% rename from config/letsencrypt/crypto.go rename to caddy/letsencrypt/crypto.go diff --git a/config/letsencrypt/crypto_test.go b/caddy/letsencrypt/crypto_test.go similarity index 100% rename from config/letsencrypt/crypto_test.go rename to caddy/letsencrypt/crypto_test.go diff --git a/config/letsencrypt/letsencrypt.go b/caddy/letsencrypt/letsencrypt.go similarity index 97% rename from config/letsencrypt/letsencrypt.go rename to caddy/letsencrypt/letsencrypt.go index 632e8000..a7aef7e8 100644 --- a/config/letsencrypt/letsencrypt.go +++ b/caddy/letsencrypt/letsencrypt.go @@ -18,6 +18,12 @@ import ( "github.com/xenolf/lego/acme" ) +// OnRenew is the function that will be used to restart +// the application or the part of the application that uses +// the certificates maintained by this package. When at least +// one certificate is renewed, this function will be called. +var OnRenew func() error + // Activate sets up TLS for each server config in configs // as needed. It only skips the config if the cert and key // are already provided or if plaintext http is explicitly diff --git a/config/letsencrypt/letsencrypt_test.go b/caddy/letsencrypt/letsencrypt_test.go similarity index 100% rename from config/letsencrypt/letsencrypt_test.go rename to caddy/letsencrypt/letsencrypt_test.go diff --git a/config/letsencrypt/renew.go b/caddy/letsencrypt/renew.go similarity index 88% rename from config/letsencrypt/renew.go rename to caddy/letsencrypt/renew.go index 291df06c..cd19c24e 100644 --- a/config/letsencrypt/renew.go +++ b/caddy/letsencrypt/renew.go @@ -17,10 +17,16 @@ import ( func keepCertificatesRenewed(configs []server.Config) { ticker := time.Tick(renewInterval) for range ticker { - if errs := processCertificateRenewal(configs); len(errs) > 0 { + if n, errs := processCertificateRenewal(configs); len(errs) > 0 { for _, err := range errs { log.Printf("[ERROR] cert renewal: %v\n", err) } + if n > 0 && OnRenew != nil { + err := OnRenew() + if err != nil { + log.Printf("[ERROR] onrenew callback: %v\n", err) + } + } } } } @@ -28,9 +34,11 @@ func keepCertificatesRenewed(configs []server.Config) { // checkCertificateRenewal loops through all configured // sites and looks for certificates to renew. Nothing is mutated // through this function. The changes happen directly on disk. -func processCertificateRenewal(configs []server.Config) []error { - var errs []error +// It returns the number of certificates renewed and +func processCertificateRenewal(configs []server.Config) (int, []error) { log.Print("[INFO] Processing certificate renewals...") + var errs []error + var n int for _, cfg := range configs { // Host must be TLS-enabled and have assets managed by LE @@ -95,11 +103,12 @@ func processCertificateRenewal(configs []server.Config) []error { } saveCertsAndKeys([]acme.CertificateResource{newCertMeta}) + n++ } else if daysLeft <= 14 { // Warn on 14 days remaining log.Printf("[WARN] There are %d days left on the certificate for %s. Will renew when 7 days remain.\n", daysLeft, cfg.Host) } } - return errs + return n, errs } diff --git a/config/letsencrypt/storage.go b/caddy/letsencrypt/storage.go similarity index 95% rename from config/letsencrypt/storage.go rename to caddy/letsencrypt/storage.go index ca4405a8..6826e930 100644 --- a/config/letsencrypt/storage.go +++ b/caddy/letsencrypt/storage.go @@ -4,13 +4,13 @@ import ( "path/filepath" "strings" - "github.com/mholt/caddy/app" + "github.com/mholt/caddy/caddy/assets" ) // storage is used to get file paths in a consistent, // cross-platform way for persisting Let's Encrypt assets // on the file system. -var storage = Storage(filepath.Join(app.DataFolder(), "letsencrypt")) +var storage = Storage(filepath.Join(assets.Path(), "letsencrypt")) // Storage is a root directory and facilitates // forming file paths derived from it. diff --git a/config/letsencrypt/storage_test.go b/caddy/letsencrypt/storage_test.go similarity index 100% rename from config/letsencrypt/storage_test.go rename to caddy/letsencrypt/storage_test.go diff --git a/config/letsencrypt/user.go b/caddy/letsencrypt/user.go similarity index 100% rename from config/letsencrypt/user.go rename to caddy/letsencrypt/user.go diff --git a/config/letsencrypt/user_test.go b/caddy/letsencrypt/user_test.go similarity index 100% rename from config/letsencrypt/user_test.go rename to caddy/letsencrypt/user_test.go diff --git a/config/parse/dispenser.go b/caddy/parse/dispenser.go similarity index 100% rename from config/parse/dispenser.go rename to caddy/parse/dispenser.go diff --git a/config/parse/dispenser_test.go b/caddy/parse/dispenser_test.go similarity index 100% rename from config/parse/dispenser_test.go rename to caddy/parse/dispenser_test.go diff --git a/config/parse/import_test1.txt b/caddy/parse/import_test1.txt similarity index 100% rename from config/parse/import_test1.txt rename to caddy/parse/import_test1.txt diff --git a/config/parse/import_test2.txt b/caddy/parse/import_test2.txt similarity index 100% rename from config/parse/import_test2.txt rename to caddy/parse/import_test2.txt diff --git a/config/parse/lexer.go b/caddy/parse/lexer.go similarity index 100% rename from config/parse/lexer.go rename to caddy/parse/lexer.go diff --git a/config/parse/lexer_test.go b/caddy/parse/lexer_test.go similarity index 100% rename from config/parse/lexer_test.go rename to caddy/parse/lexer_test.go diff --git a/config/parse/parse.go b/caddy/parse/parse.go similarity index 100% rename from config/parse/parse.go rename to caddy/parse/parse.go diff --git a/config/parse/parse_test.go b/caddy/parse/parse_test.go similarity index 100% rename from config/parse/parse_test.go rename to caddy/parse/parse_test.go diff --git a/config/parse/parsing.go b/caddy/parse/parsing.go similarity index 100% rename from config/parse/parsing.go rename to caddy/parse/parsing.go diff --git a/config/parse/parsing_test.go b/caddy/parse/parsing_test.go similarity index 100% rename from config/parse/parsing_test.go rename to caddy/parse/parsing_test.go diff --git a/config/setup/basicauth.go b/caddy/setup/basicauth.go similarity index 100% rename from config/setup/basicauth.go rename to caddy/setup/basicauth.go diff --git a/config/setup/basicauth_test.go b/caddy/setup/basicauth_test.go similarity index 100% rename from config/setup/basicauth_test.go rename to caddy/setup/basicauth_test.go diff --git a/config/setup/bindhost.go b/caddy/setup/bindhost.go similarity index 100% rename from config/setup/bindhost.go rename to caddy/setup/bindhost.go diff --git a/config/setup/browse.go b/caddy/setup/browse.go similarity index 100% rename from config/setup/browse.go rename to caddy/setup/browse.go diff --git a/config/setup/controller.go b/caddy/setup/controller.go similarity index 98% rename from config/setup/controller.go rename to caddy/setup/controller.go index 04873082..02b366cd 100644 --- a/config/setup/controller.go +++ b/caddy/setup/controller.go @@ -5,7 +5,7 @@ import ( "net/http" "strings" - "github.com/mholt/caddy/config/parse" + "github.com/mholt/caddy/caddy/parse" "github.com/mholt/caddy/middleware" "github.com/mholt/caddy/server" ) diff --git a/config/setup/errors.go b/caddy/setup/errors.go similarity index 100% rename from config/setup/errors.go rename to caddy/setup/errors.go diff --git a/config/setup/errors_test.go b/caddy/setup/errors_test.go similarity index 100% rename from config/setup/errors_test.go rename to caddy/setup/errors_test.go diff --git a/config/setup/ext.go b/caddy/setup/ext.go similarity index 100% rename from config/setup/ext.go rename to caddy/setup/ext.go diff --git a/config/setup/ext_test.go b/caddy/setup/ext_test.go similarity index 100% rename from config/setup/ext_test.go rename to caddy/setup/ext_test.go diff --git a/config/setup/fastcgi.go b/caddy/setup/fastcgi.go similarity index 100% rename from config/setup/fastcgi.go rename to caddy/setup/fastcgi.go diff --git a/config/setup/fastcgi_test.go b/caddy/setup/fastcgi_test.go similarity index 100% rename from config/setup/fastcgi_test.go rename to caddy/setup/fastcgi_test.go diff --git a/config/setup/gzip.go b/caddy/setup/gzip.go similarity index 100% rename from config/setup/gzip.go rename to caddy/setup/gzip.go diff --git a/config/setup/gzip_test.go b/caddy/setup/gzip_test.go similarity index 100% rename from config/setup/gzip_test.go rename to caddy/setup/gzip_test.go diff --git a/config/setup/headers.go b/caddy/setup/headers.go similarity index 100% rename from config/setup/headers.go rename to caddy/setup/headers.go diff --git a/config/setup/headers_test.go b/caddy/setup/headers_test.go similarity index 100% rename from config/setup/headers_test.go rename to caddy/setup/headers_test.go diff --git a/config/setup/internal.go b/caddy/setup/internal.go similarity index 100% rename from config/setup/internal.go rename to caddy/setup/internal.go diff --git a/config/setup/internal_test.go b/caddy/setup/internal_test.go similarity index 100% rename from config/setup/internal_test.go rename to caddy/setup/internal_test.go diff --git a/config/setup/log.go b/caddy/setup/log.go similarity index 100% rename from config/setup/log.go rename to caddy/setup/log.go diff --git a/config/setup/log_test.go b/caddy/setup/log_test.go similarity index 100% rename from config/setup/log_test.go rename to caddy/setup/log_test.go diff --git a/config/setup/markdown.go b/caddy/setup/markdown.go similarity index 100% rename from config/setup/markdown.go rename to caddy/setup/markdown.go diff --git a/config/setup/markdown_test.go b/caddy/setup/markdown_test.go similarity index 100% rename from config/setup/markdown_test.go rename to caddy/setup/markdown_test.go diff --git a/config/setup/mime.go b/caddy/setup/mime.go similarity index 100% rename from config/setup/mime.go rename to caddy/setup/mime.go diff --git a/config/setup/mime_test.go b/caddy/setup/mime_test.go similarity index 100% rename from config/setup/mime_test.go rename to caddy/setup/mime_test.go diff --git a/config/setup/proxy.go b/caddy/setup/proxy.go similarity index 100% rename from config/setup/proxy.go rename to caddy/setup/proxy.go diff --git a/config/setup/redir.go b/caddy/setup/redir.go similarity index 100% rename from config/setup/redir.go rename to caddy/setup/redir.go diff --git a/config/setup/rewrite.go b/caddy/setup/rewrite.go similarity index 100% rename from config/setup/rewrite.go rename to caddy/setup/rewrite.go diff --git a/config/setup/rewrite_test.go b/caddy/setup/rewrite_test.go similarity index 100% rename from config/setup/rewrite_test.go rename to caddy/setup/rewrite_test.go diff --git a/config/setup/roller.go b/caddy/setup/roller.go similarity index 100% rename from config/setup/roller.go rename to caddy/setup/roller.go diff --git a/config/setup/root.go b/caddy/setup/root.go similarity index 100% rename from config/setup/root.go rename to caddy/setup/root.go diff --git a/config/setup/root_test.go b/caddy/setup/root_test.go similarity index 100% rename from config/setup/root_test.go rename to caddy/setup/root_test.go diff --git a/config/setup/startupshutdown.go b/caddy/setup/startupshutdown.go similarity index 100% rename from config/setup/startupshutdown.go rename to caddy/setup/startupshutdown.go diff --git a/config/setup/templates.go b/caddy/setup/templates.go similarity index 100% rename from config/setup/templates.go rename to caddy/setup/templates.go diff --git a/config/setup/templates_test.go b/caddy/setup/templates_test.go similarity index 100% rename from config/setup/templates_test.go rename to caddy/setup/templates_test.go diff --git a/config/setup/testdata/blog/first_post.md b/caddy/setup/testdata/blog/first_post.md similarity index 100% rename from config/setup/testdata/blog/first_post.md rename to caddy/setup/testdata/blog/first_post.md diff --git a/config/setup/testdata/header.html b/caddy/setup/testdata/header.html similarity index 100% rename from config/setup/testdata/header.html rename to caddy/setup/testdata/header.html diff --git a/config/setup/testdata/tpl_with_include.html b/caddy/setup/testdata/tpl_with_include.html similarity index 100% rename from config/setup/testdata/tpl_with_include.html rename to caddy/setup/testdata/tpl_with_include.html diff --git a/config/setup/tls.go b/caddy/setup/tls.go similarity index 100% rename from config/setup/tls.go rename to caddy/setup/tls.go diff --git a/config/setup/tls_test.go b/caddy/setup/tls_test.go similarity index 100% rename from config/setup/tls_test.go rename to caddy/setup/tls_test.go diff --git a/config/setup/websocket.go b/caddy/setup/websocket.go similarity index 100% rename from config/setup/websocket.go rename to caddy/setup/websocket.go diff --git a/config/setup/websocket_test.go b/caddy/setup/websocket_test.go similarity index 100% rename from config/setup/websocket_test.go rename to caddy/setup/websocket_test.go diff --git a/main.go b/main.go index b080d663..68aab11c 100644 --- a/main.go +++ b/main.go @@ -1,24 +1,19 @@ package main import ( - "bytes" + "errors" "flag" "fmt" "io/ioutil" "log" - "net" "os" - "os/exec" - "path" "runtime" "strconv" "strings" "time" - "github.com/mholt/caddy/app" - "github.com/mholt/caddy/config" - "github.com/mholt/caddy/config/letsencrypt" - "github.com/mholt/caddy/server" + "github.com/mholt/caddy/caddy" + "github.com/mholt/caddy/caddy/letsencrypt" ) var ( @@ -28,25 +23,33 @@ var ( revoke string ) +const ( + appName = "Caddy" + appVersion = "0.8 beta" +) + func init() { - flag.StringVar(&conf, "conf", "", "Configuration file to use (default="+config.DefaultConfigFile+")") - flag.BoolVar(&app.HTTP2, "http2", true, "Enable HTTP/2 support") // TODO: temporary flag until http2 merged into std lib - flag.BoolVar(&app.Quiet, "quiet", false, "Quiet mode (no initialization output)") + flag.StringVar(&conf, "conf", "", "Configuration file to use (default="+caddy.DefaultConfigFile+")") + flag.BoolVar(&caddy.HTTP2, "http2", true, "Enable HTTP/2 support") // TODO: temporary flag until http2 merged into std lib + flag.BoolVar(&caddy.Quiet, "quiet", false, "Quiet mode (no initialization output)") flag.StringVar(&cpu, "cpu", "100%", "CPU cap") - flag.StringVar(&config.Root, "root", config.DefaultRoot, "Root path to default site") - flag.StringVar(&config.Host, "host", config.DefaultHost, "Default host") - flag.StringVar(&config.Port, "port", config.DefaultPort, "Default port") + flag.StringVar(&caddy.Root, "root", caddy.DefaultRoot, "Root path to default site") + flag.StringVar(&caddy.Host, "host", caddy.DefaultHost, "Default host") + flag.StringVar(&caddy.Port, "port", caddy.DefaultPort, "Default port") flag.BoolVar(&version, "version", false, "Show version") flag.BoolVar(&letsencrypt.Agreed, "agree", false, "Agree to Let's Encrypt Subscriber Agreement") flag.StringVar(&letsencrypt.DefaultEmail, "email", "", "Default email address to use for Let's Encrypt transactions") - flag.StringVar(&revoke, "revoke", "", "Hostname for which to revoke the certificate") + flag.StringVar(&revoke, "revoke", "", "Hostname for which to revoke its certificate") } func main() { flag.Parse() + caddy.AppName = appName + caddy.AppVersion = appVersion + if version { - fmt.Printf("%s %s\n", app.Name, app.Version) + fmt.Printf("%s %s\n", caddy.AppName, caddy.AppVersion) os.Exit(0) } if revoke != "" { @@ -59,165 +62,103 @@ func main() { } // Set CPU cap - err := app.SetCPU(cpu) + err := setCPU(cpu) if err != nil { log.Fatal(err) } - // Load config from file - groupings, err := loadConfigs() + // Get Caddyfile input + caddyfile, err := caddy.LoadCaddyfile(loadCaddyfile) if err != nil { log.Fatal(err) } - // Start each server with its one or more configurations - for i, group := range groupings { - s, err := server.New(group.BindAddr.String(), group.Configs) - if err != nil { - log.Fatal(err) - } - s.HTTP2 = app.HTTP2 // TODO: This setting is temporary - - app.Wg.Add(1) - go func(s *server.Server, i int) { - defer app.Wg.Done() - - if os.Getenv("CADDY_RESTART") == "true" { - file := os.NewFile(uintptr(3+i), "") - ln, err := net.FileListener(file) - if err != nil { - log.Fatal("FILE LISTENER:", err) - } - - lnf, ok := ln.(server.ListenerFile) - if !ok { - log.Fatal("Listener was not a ListenerFile") - } - - err = s.Serve(lnf) - // TODO: Better error logging... also, is it even necessary? - if err != nil { - log.Println(err) - } - } else { - err := s.ListenAndServe() - // TODO: Better error logging... also, is it even necessary? - // For example, "use of closed network connection" is normal if doing graceful shutdown... - if err != nil { - log.Println(err) - } - } - }(s, i) - - app.ServersMutex.Lock() - app.Servers = append(app.Servers, s) - app.ServersMutex.Unlock() - } - - // Show initialization output - if !app.Quiet { - var checkedFdLimit bool - for _, group := range groupings { - for _, conf := range group.Configs { - // Print address of site - fmt.Println(conf.Address()) - - // Note if non-localhost site resolves to loopback interface - if group.BindAddr.IP.IsLoopback() && !isLocalhost(conf.Host) { - fmt.Printf("Notice: %s is only accessible on this machine (%s)\n", - conf.Host, group.BindAddr.IP.String()) - } - if !checkedFdLimit && !group.BindAddr.IP.IsLoopback() && !isLocalhost(conf.Host) { - checkFdlimit() - checkedFdLimit = true - } - } - } + // Start your engines + err = caddy.Start(caddyfile) + if err != nil { + log.Fatal(err) } // TODO: Temporary; testing restart - if os.Getenv("CADDY_RESTART") != "true" { - go func() { - time.Sleep(5 * time.Second) - fmt.Println("restarting") - log.Println("RESTART ERR:", app.Restart([]byte{})) - }() - } + //if os.Getenv("CADDY_RESTART") != "true" { + go func() { + time.Sleep(5 * time.Second) + fmt.Println("restarting") + log.Println("RESTART ERR:", caddy.Restart(nil)) + }() + //} - // Wait for all servers to be stopped - app.Wg.Wait() + // Twiddle your thumbs + caddy.Wait() } -// checkFdlimit issues a warning if the OS max file descriptors is below a recommended minimum. -func checkFdlimit() { - const min = 4096 - - // Warn if ulimit is too low for production sites - if runtime.GOOS == "linux" || runtime.GOOS == "darwin" { - out, err := exec.Command("sh", "-c", "ulimit -n").Output() // use sh because ulimit isn't in Linux $PATH - if err == nil { - // Note that an error here need not be reported - lim, err := strconv.Atoi(string(bytes.TrimSpace(out))) - if err == nil && lim < min { - fmt.Printf("Warning: File descriptor limit %d is too low for production sites. At least %d is recommended. Set with \"ulimit -n %d\".\n", lim, min, min) - } - } - } -} - -// isLocalhost returns true if the string looks explicitly like a localhost address. -func isLocalhost(s string) bool { - return s == "localhost" || s == "::1" || strings.HasPrefix(s, "127.") -} - -// loadConfigs loads configuration from a file or stdin (piped). -// The configurations are grouped by bind address. -// Configuration is obtained from one of four sources, tried -// in this order: 1. -conf flag, 2. stdin, 3. command line argument 4. Caddyfile. -// If none of those are available, a default configuration is loaded. -func loadConfigs() (config.Group, error) { +func loadCaddyfile() (caddy.Input, error) { // -conf flag if conf != "" { - file, err := os.Open(conf) + contents, err := ioutil.ReadFile(conf) if err != nil { return nil, err } - defer file.Close() - return config.Load(path.Base(conf), file) + return caddy.CaddyfileInput{ + Contents: contents, + Filepath: conf, + }, nil } - // stdin - fi, err := os.Stdin.Stat() - if err == nil && fi.Mode()&os.ModeCharDevice == 0 { - // Note that a non-nil error is not a problem. Windows - // will not create a stdin if there is no pipe, which - // produces an error when calling Stat(). But Unix will - // make one either way, which is why we also check that - // bitmask. - confBody, err := ioutil.ReadAll(os.Stdin) - if err != nil { - log.Fatal(err) - } - if len(confBody) > 0 { - return config.Load("stdin", bytes.NewReader(confBody)) - } - } - - // Command line args + // command line args if flag.NArg() > 0 { - confBody := ":" + config.DefaultPort + "\n" + strings.Join(flag.Args(), "\n") - return config.Load("args", bytes.NewBufferString(confBody)) + confBody := ":" + caddy.DefaultPort + "\n" + strings.Join(flag.Args(), "\n") + return caddy.CaddyfileInput{ + Contents: []byte(confBody), + Filepath: "args", + }, nil } - // Caddyfile - file, err := os.Open(config.DefaultConfigFile) + // Caddyfile in cwd + contents, err := ioutil.ReadFile(caddy.DefaultConfigFile) if err != nil { if os.IsNotExist(err) { - return config.Default() + return caddy.DefaultInput, nil } return nil, err } - defer file.Close() - - return config.Load(config.DefaultConfigFile, file) + return caddy.CaddyfileInput{ + Contents: contents, + Filepath: caddy.DefaultConfigFile, + }, nil +} + +// setCPU parses string cpu and sets GOMAXPROCS +// according to its value. It accepts either +// a number (e.g. 3) or a percent (e.g. 50%). +func setCPU(cpu string) error { + var numCPU int + + availCPU := runtime.NumCPU() + + if strings.HasSuffix(cpu, "%") { + // Percent + var percent float32 + pctStr := cpu[:len(cpu)-1] + pctInt, err := strconv.Atoi(pctStr) + if err != nil || pctInt < 1 || pctInt > 100 { + return errors.New("invalid CPU value: percentage must be between 1-100") + } + percent = float32(pctInt) / 100 + numCPU = int(float32(availCPU) * percent) + } else { + // Number + num, err := strconv.Atoi(cpu) + if err != nil || num < 1 { + return errors.New("invalid CPU value: provide a number or percent greater than 0") + } + numCPU = num + } + + if numCPU > availCPU { + numCPU = availCPU + } + + runtime.GOMAXPROCS(numCPU) + return nil } diff --git a/middleware/proxy/upstream.go b/middleware/proxy/upstream.go index 3ab8aa9b..f068907e 100644 --- a/middleware/proxy/upstream.go +++ b/middleware/proxy/upstream.go @@ -9,7 +9,7 @@ import ( "strings" "time" - "github.com/mholt/caddy/config/parse" + "github.com/mholt/caddy/caddy/parse" ) var ( diff --git a/server/graceful.go b/server/graceful.go index 8f74ec96..6b2ae4f5 100644 --- a/server/graceful.go +++ b/server/graceful.go @@ -65,6 +65,12 @@ type gracefulConn struct { // Close closes c's underlying connection while updating the wg count. func (c gracefulConn) Close() error { + err := c.Conn.Close() + if err != nil { + return err + } + // close can fail on http2 connections (as of Oct. 2015, before http2 in std lib) + // so don't decrement count unless close succeeds c.httpWg.Done() - return c.Conn.Close() + return nil } diff --git a/server/server.go b/server/server.go index 9ead4621..befbe86c 100644 --- a/server/server.go +++ b/server/server.go @@ -59,14 +59,13 @@ func New(addr string, configs []Config) (*Server, error) { tls: tls, vhosts: make(map[string]virtualHost), } - s.Handler = s // TODO: this is weird + s.Handler = s // this is weird, but whatever // We have to bound our wg with one increment // to prevent a "race condition" that is hard-coded // into sync.WaitGroup.Wait() - basically, an add // with a positive delta must be guaranteed to // occur before Wait() is called on the wg. - fmt.Println("+1 (new)") s.httpWg.Add(1) // Set up each virtualhost @@ -169,11 +168,6 @@ func (s *Server) setup() error { // by the Go Authors. It has been modified to support multiple certificate/key pairs, // client authentication, and our custom Server type. func serveTLSWithSNI(s *Server, ln net.Listener, tlsConfigs []TLSConfig) error { - addr := s.Server.Addr - if addr == "" { - addr = ":https" - } - config := cloneTLSConfig(s.TLSConfig) if config.NextProtos == nil { config.NextProtos = []string{"http/1.1"} @@ -267,7 +261,7 @@ func (s *Server) ListenerFd() uintptr { // (configuration and middleware stack) will handle the request. func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { fmt.Println("Sleeping") - time.Sleep(5 * time.Second) + time.Sleep(5 * time.Second) // TODO: Temporarily making requests hang so we can test graceful restart fmt.Println("Unblocking") defer func() { // In case the user doesn't enable error middleware, we still From 41c4484222ecd9efc4b5f48c3c66a5e87a2ff532 Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Mon, 26 Oct 2015 14:28:29 -0600 Subject: [PATCH 40/73] core: SIGUSR1 to reload config; some code cleanup --- caddy/caddy.go | 159 +++++++++++++++++++++++++++-------------------- main.go | 10 --- server/server.go | 3 - 3 files changed, 91 insertions(+), 81 deletions(-) diff --git a/caddy/caddy.go b/caddy/caddy.go index 1cc039b6..ac099690 100644 --- a/caddy/caddy.go +++ b/caddy/caddy.go @@ -105,58 +105,15 @@ func Start(cdyfile Input) error { caddyfile = cdyfile caddyfileMu.Unlock() - groupings, err := Load(path.Base(caddyfile.Path()), bytes.NewReader(caddyfile.Body())) + groupings, err := Load(path.Base(cdyfile.Path()), bytes.NewReader(cdyfile.Body())) + if err != nil { + return err + } // Start each server with its one or more configurations - for i, group := range groupings { - s, err := server.New(group.BindAddr.String(), group.Configs) - if err != nil { - log.Fatal(err) - } - s.HTTP2 = HTTP2 // TODO: This setting is temporary - - var ln server.ListenerFile - if isRestart() { - // Look up this server's listener in the map of inherited file descriptors; - // if we don't have one, we must make a new one. - if fdIndex, ok := loadedGob.ListenerFds[s.Addr]; ok { - file := os.NewFile(fdIndex, "") - - fln, err := net.FileListener(file) - if err != nil { - log.Fatal("FILE LISTENER:", err) - } - - ln, ok = fln.(server.ListenerFile) - if !ok { - log.Fatal("Listener was not a ListenerFile") - } - - delete(loadedGob.ListenerFds, s.Addr) // mark it as used - } - } - - wg.Add(1) - go func(s *server.Server, i int, ln server.ListenerFile) { - defer wg.Done() - if ln == nil { - err := s.ListenAndServe() - // "use of closed network connection" is normal if doing graceful shutdown... - if !strings.Contains(err.Error(), "use of closed network connection") { - // But an error at initial startup must be fatal - log.Fatal(err) - } - } else { - err := s.Serve(ln) - if err != nil { - log.Println(err) - } - } - }(s, i, ln) - - serversMu.Lock() - servers = append(servers, s) - serversMu.Unlock() + err = startServers(groupings) + if err != nil { + return err } // Close remaining file descriptors we may have inherited that we don't need @@ -191,7 +148,7 @@ func Start(cdyfile Input) error { } } - // Tell parent we're A-OK + // Tell parent process that we got this if isRestart() { file := os.NewFile(3, "") file.Write([]byte("success")) @@ -201,6 +158,64 @@ func Start(cdyfile Input) error { return nil } +// startServers starts all the servers in groupings, +// taking into account whether or not this process is +// a child from a graceful restart or not. +func startServers(groupings Group) error { + for i, group := range groupings { + s, err := server.New(group.BindAddr.String(), group.Configs) + if err != nil { + log.Fatal(err) + } + s.HTTP2 = HTTP2 // TODO: This setting is temporary + + var ln server.ListenerFile + if isRestart() { + // Look up this server's listener in the map of inherited file descriptors; + // if we don't have one, we must make a new one. + if fdIndex, ok := loadedGob.ListenerFds[s.Addr]; ok { + file := os.NewFile(fdIndex, "") + + fln, err := net.FileListener(file) + if err != nil { + log.Fatal(err) + } + + ln, ok = fln.(server.ListenerFile) + if !ok { + log.Fatal("listener was not a ListenerFile") + } + + delete(loadedGob.ListenerFds, s.Addr) // mark it as used + } + } + + wg.Add(1) + go func(s *server.Server, i int, ln server.ListenerFile) { + defer wg.Done() + if ln != nil { + err = s.Serve(ln) + } else { + err = s.ListenAndServe() + } + + // "use of closed network connection" is normal if doing graceful shutdown... + if err != nil && !strings.Contains(err.Error(), "use of closed network connection") { + if isRestart() { + log.Fatal(err) + } else { + log.Println(err) + } + } + }(s, i, ln) + + serversMu.Lock() + servers = append(servers, s) + serversMu.Unlock() + } + return nil +} + // isLocalhost returns true if the string looks explicitly like a localhost address. func isLocalhost(s string) bool { return s == "localhost" || s == "::1" || strings.HasPrefix(s, "127.") @@ -302,9 +317,9 @@ func Restart(newCaddyfile Input) error { Env: os.Environ(), Files: fds, } - pid, err := syscall.ForkExec(os.Args[0], os.Args, execSpec) + _, err = syscall.ForkExec(os.Args[0], os.Args, execSpec) if err != nil { - log.Println("FORK ERR:", err, pid) + return err } // Feed it the Caddyfile @@ -447,24 +462,32 @@ func init() { // Trap signals go func() { - // Wait for signal - interrupt := make(chan os.Signal, 1) - signal.Notify(interrupt, os.Interrupt, os.Kill) - <-interrupt + shutdown, reload := make(chan os.Signal, 1), make(chan os.Signal, 1) + signal.Notify(shutdown, os.Interrupt, os.Kill) // quit the process + signal.Notify(reload, syscall.SIGUSR1) // reload configuration - // TODO: A signal just for graceful restart (reload config) - maybe SIGUSR1 + for { + select { + case <-shutdown: + var exitCode int - // Run shutdown callbacks - var exitCode int - serversMu.Lock() - errs := server.ShutdownCallbacks(servers) - serversMu.Unlock() - if len(errs) > 0 { - for _, err := range errs { - log.Println(err) + serversMu.Lock() + errs := server.ShutdownCallbacks(servers) + serversMu.Unlock() + if len(errs) > 0 { + for _, err := range errs { + log.Println(err) + } + exitCode = 1 + } + os.Exit(exitCode) + + case <-reload: + err := Restart(nil) + if err != nil { + log.Println(err) + } } - exitCode = 1 } - os.Exit(exitCode) }() } diff --git a/main.go b/main.go index 68aab11c..8e4bffc2 100644 --- a/main.go +++ b/main.go @@ -10,7 +10,6 @@ import ( "runtime" "strconv" "strings" - "time" "github.com/mholt/caddy/caddy" "github.com/mholt/caddy/caddy/letsencrypt" @@ -79,15 +78,6 @@ func main() { log.Fatal(err) } - // TODO: Temporary; testing restart - //if os.Getenv("CADDY_RESTART") != "true" { - go func() { - time.Sleep(5 * time.Second) - fmt.Println("restarting") - log.Println("RESTART ERR:", caddy.Restart(nil)) - }() - //} - // Twiddle your thumbs caddy.Wait() } diff --git a/server/server.go b/server/server.go index befbe86c..0a4dd4ba 100644 --- a/server/server.go +++ b/server/server.go @@ -260,9 +260,6 @@ func (s *Server) ListenerFd() uintptr { // defined in the Host header so that the correct virtualhost // (configuration and middleware stack) will handle the request. func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { - fmt.Println("Sleeping") - time.Sleep(5 * time.Second) // TODO: Temporarily making requests hang so we can test graceful restart - fmt.Println("Unblocking") defer func() { // In case the user doesn't enable error middleware, we still // need to make sure that we stay alive up here From 5b1962303dadd158e74757118f7ddcc6f51f1e39 Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Mon, 26 Oct 2015 14:55:03 -0600 Subject: [PATCH 41/73] core: More refactoring, code cleanup, docs --- caddy/caddy.go | 213 +++++------------------------------------------ caddy/helpers.go | 74 ++++++++++++++++ caddy/restart.go | 132 +++++++++++++++++++++++++++++ 3 files changed, 227 insertions(+), 192 deletions(-) create mode 100644 caddy/helpers.go create mode 100644 caddy/restart.go diff --git a/caddy/caddy.go b/caddy/caddy.go index ac099690..b80450ee 100644 --- a/caddy/caddy.go +++ b/caddy/caddy.go @@ -1,3 +1,16 @@ +// Package caddy implements the Caddy web server as a service. +// +// To use this package, follow a few simple steps: +// +// 1. Set the AppName and AppVersion variables. +// 2. Call LoadCaddyfile() to get the Caddyfile (it +// might have been piped in as part of a restart). +// You should pass in your own Caddyfile loader. +// 3. Call caddy.Start() to start Caddy, caddy.Stop() +// to stop it, or caddy.Restart() to restart it. +// +// You should use caddy.Wait() to wait for all Caddy servers +// to quit before your process exits. package caddy import ( @@ -9,16 +22,10 @@ import ( "log" "net" "os" - "os/exec" - "os/signal" "path" - "runtime" - "strconv" "strings" "sync" - "syscall" - "github.com/mholt/caddy/caddy/letsencrypt" "github.com/mholt/caddy/server" ) @@ -72,14 +79,6 @@ const ( DefaultRoot = "." ) -// caddyfileGob maps bind address to index of the file descriptor -// in the Files array passed to the child process. It also contains -// the caddyfile contents. -type caddyfileGob struct { - ListenerFds map[string]uintptr - Caddyfile []byte -} - // Start starts Caddy with the given Caddyfile. If cdyfile // is nil or the process is forked from a parent as part of // a graceful restart, Caddy will check to see if Caddyfile @@ -216,28 +215,7 @@ func startServers(groupings Group) error { return nil } -// isLocalhost returns true if the string looks explicitly like a localhost address. -func isLocalhost(s string) bool { - return s == "localhost" || s == "::1" || strings.HasPrefix(s, "127.") -} - -// checkFdlimit issues a warning if the OS max file descriptors is below a recommended minimum. -func checkFdlimit() { - const min = 4096 - - // Warn if ulimit is too low for production sites - if runtime.GOOS == "linux" || runtime.GOOS == "darwin" { - out, err := exec.Command("sh", "-c", "ulimit -n").Output() // use sh because ulimit isn't in Linux $PATH - if err == nil { - // Note that an error here need not be reported - lim, err := strconv.Atoi(string(bytes.TrimSpace(out))) - if err == nil && lim < min { - fmt.Printf("Warning: File descriptor limit %d is too low for production sites. At least %d is recommended. Set with \"ulimit -n %d\".\n", lim, min, min) - } - } - } -} - +// Stop stops all servers. It blocks until they are all stopped. func Stop() error { serversMu.Lock() for _, s := range servers { @@ -247,100 +225,6 @@ func Stop() error { return nil } -// Restart restarts the entire application; gracefully with zero -// downtime if on a POSIX-compatible system, or forcefully if on -// Windows but with imperceptibly-short downtime. -// -// The restarted application will use newCaddyfile as its input -// configuration. If newCaddyfile is nil, the current (existing) -// Caddyfile configuration will be used. -func Restart(newCaddyfile Input) error { - if newCaddyfile == nil { - caddyfileMu.Lock() - newCaddyfile = caddyfile - caddyfileMu.Unlock() - } - - if runtime.GOOS == "windows" { - err := Stop() - if err != nil { - return err - } - err = Start(newCaddyfile) - if err != nil { - return err - } - return nil - } - - if len(os.Args) == 0 { // this should never happen, but just in case... - os.Args = []string{""} - } - - // Tell the child that it's a restart - os.Setenv("CADDY_RESTART", "true") - - // Prepare our payload to the child process - cdyfileGob := caddyfileGob{ - ListenerFds: make(map[string]uintptr), - Caddyfile: newCaddyfile.Body(), - } - - // Prepare a pipe to the fork's stdin so it can get the Caddyfile - rpipe, wpipe, err := os.Pipe() - if err != nil { - return err - } - - // Prepare a pipe that the child process will use to communicate - // its success or failure with us, the parent - sigrpipe, sigwpipe, err := os.Pipe() - if err != nil { - return err - } - - // Pass along current environment and file descriptors to child. - // Ordering here is very important: stdin, stdout, stderr, sigpipe, - // and then the listener file descriptors (in order). - fds := []uintptr{rpipe.Fd(), os.Stdout.Fd(), os.Stderr.Fd(), sigwpipe.Fd()} - - // Now add file descriptors of the sockets - serversMu.Lock() - for i, s := range servers { - fds = append(fds, s.ListenerFd()) - cdyfileGob.ListenerFds[s.Addr] = uintptr(4 + i) // 4 fds come before any of the listeners - } - serversMu.Unlock() - - // Fork the process with the current environment and file descriptors - execSpec := &syscall.ProcAttr{ - Env: os.Environ(), - Files: fds, - } - _, err = syscall.ForkExec(os.Args[0], os.Args, execSpec) - if err != nil { - return err - } - - // Feed it the Caddyfile - err = gob.NewEncoder(wpipe).Encode(cdyfileGob) - if err != nil { - return err - } - wpipe.Close() - - // Wait for child process to signal success or fail - sigwpipe.Close() // close our copy of the write end of the pipe - answer, err := ioutil.ReadAll(sigrpipe) - if err != nil || len(answer) == 0 { - log.Println("restart: child failed to answer; changes not applied") - return incompleteRestartErr - } - - // Child process is listening now; we can stop all our servers here. - return Stop() -} - // Wait blocks until all servers are stopped. func Wait() { wg.Wait() @@ -387,19 +271,6 @@ func LoadCaddyfile(loader func() (Input, error)) (cdyfile Input, err error) { return } -// Caddyfile returns the current Caddyfile -func Caddyfile() Input { - caddyfileMu.Lock() - defer caddyfileMu.Unlock() - return caddyfile -} - -// isRestart returns whether this process is, according -// to env variables, a fork as part of a graceful restart. -func isRestart() bool { - return os.Getenv("CADDY_RESTART") == "true" -} - // CaddyfileFromPipe loads the Caddyfile input from f if f is // not interactive input. f is assumed to be a pipe or stream, // such as os.Stdin. If f is not a pipe, no error is returned @@ -430,6 +301,13 @@ func CaddyfileFromPipe(f *os.File) (Input, error) { return nil, nil } +// Caddyfile returns the current Caddyfile +func Caddyfile() Input { + caddyfileMu.Lock() + defer caddyfileMu.Unlock() + return caddyfile +} + // Input represents a Caddyfile; its contents and file path // (which should include the file name at the end of the path). // If path does not apply (e.g. piped input) you may use @@ -442,52 +320,3 @@ type Input interface { // Gets the path to the origin file Path() string } - -// CaddyfileInput represents a Caddyfile as input -// and is simply a convenient way to implement -// the Input interface. -type CaddyfileInput struct { - Filepath string - Contents []byte -} - -// Body returns c.Contents. -func (c CaddyfileInput) Body() []byte { return c.Contents } - -// Path returns c.Filepath. -func (c CaddyfileInput) Path() string { return c.Filepath } - -func init() { - letsencrypt.OnRenew = func() error { return Restart(nil) } - - // Trap signals - go func() { - shutdown, reload := make(chan os.Signal, 1), make(chan os.Signal, 1) - signal.Notify(shutdown, os.Interrupt, os.Kill) // quit the process - signal.Notify(reload, syscall.SIGUSR1) // reload configuration - - for { - select { - case <-shutdown: - var exitCode int - - serversMu.Lock() - errs := server.ShutdownCallbacks(servers) - serversMu.Unlock() - if len(errs) > 0 { - for _, err := range errs { - log.Println(err) - } - exitCode = 1 - } - os.Exit(exitCode) - - case <-reload: - err := Restart(nil) - if err != nil { - log.Println(err) - } - } - } - }() -} diff --git a/caddy/helpers.go b/caddy/helpers.go new file mode 100644 index 00000000..209eb7f8 --- /dev/null +++ b/caddy/helpers.go @@ -0,0 +1,74 @@ +package caddy + +import ( + "bytes" + "fmt" + "log" + "os" + "os/exec" + "os/signal" + "runtime" + "strconv" + "strings" + "syscall" + + "github.com/mholt/caddy/caddy/letsencrypt" + "github.com/mholt/caddy/server" +) + +func init() { + letsencrypt.OnRenew = func() error { return Restart(nil) } + + // Trap signals + go func() { + shutdown, reload := make(chan os.Signal, 1), make(chan os.Signal, 1) + signal.Notify(shutdown, os.Interrupt, os.Kill) // quit the process + signal.Notify(reload, syscall.SIGUSR1) // reload configuration + + for { + select { + case <-shutdown: + var exitCode int + + serversMu.Lock() + errs := server.ShutdownCallbacks(servers) + serversMu.Unlock() + if len(errs) > 0 { + for _, err := range errs { + log.Println(err) + } + exitCode = 1 + } + os.Exit(exitCode) + + case <-reload: + err := Restart(nil) + if err != nil { + log.Println(err) + } + } + } + }() +} + +// isLocalhost returns true if the string looks explicitly like a localhost address. +func isLocalhost(s string) bool { + return s == "localhost" || s == "::1" || strings.HasPrefix(s, "127.") +} + +// checkFdlimit issues a warning if the OS max file descriptors is below a recommended minimum. +func checkFdlimit() { + const min = 4096 + + // Warn if ulimit is too low for production sites + if runtime.GOOS == "linux" || runtime.GOOS == "darwin" { + out, err := exec.Command("sh", "-c", "ulimit -n").Output() // use sh because ulimit isn't in Linux $PATH + if err == nil { + // Note that an error here need not be reported + lim, err := strconv.Atoi(string(bytes.TrimSpace(out))) + if err == nil && lim < min { + fmt.Printf("Warning: File descriptor limit %d is too low for production sites. At least %d is recommended. Set with \"ulimit -n %d\".\n", lim, min, min) + } + } + } +} diff --git a/caddy/restart.go b/caddy/restart.go new file mode 100644 index 00000000..43848bf6 --- /dev/null +++ b/caddy/restart.go @@ -0,0 +1,132 @@ +package caddy + +import ( + "encoding/gob" + "io/ioutil" + "log" + "os" + "runtime" + "syscall" +) + +// caddyfileGob maps bind address to index of the file descriptor +// in the Files array passed to the child process. It also contains +// the caddyfile contents. Used only during graceful restarts. +type caddyfileGob struct { + ListenerFds map[string]uintptr + Caddyfile []byte +} + +// Restart restarts the entire application; gracefully with zero +// downtime if on a POSIX-compatible system, or forcefully if on +// Windows but with imperceptibly-short downtime. +// +// The restarted application will use newCaddyfile as its input +// configuration. If newCaddyfile is nil, the current (existing) +// Caddyfile configuration will be used. +func Restart(newCaddyfile Input) error { + if newCaddyfile == nil { + caddyfileMu.Lock() + newCaddyfile = caddyfile + caddyfileMu.Unlock() + } + + if runtime.GOOS == "windows" { + err := Stop() + if err != nil { + return err + } + err = Start(newCaddyfile) + if err != nil { + return err + } + return nil + } + + if len(os.Args) == 0 { // this should never happen, but just in case... + os.Args = []string{""} + } + + // Tell the child that it's a restart + os.Setenv("CADDY_RESTART", "true") + + // Prepare our payload to the child process + cdyfileGob := caddyfileGob{ + ListenerFds: make(map[string]uintptr), + Caddyfile: newCaddyfile.Body(), + } + + // Prepare a pipe to the fork's stdin so it can get the Caddyfile + rpipe, wpipe, err := os.Pipe() + if err != nil { + return err + } + + // Prepare a pipe that the child process will use to communicate + // its success or failure with us, the parent + sigrpipe, sigwpipe, err := os.Pipe() + if err != nil { + return err + } + + // Pass along current environment and file descriptors to child. + // Ordering here is very important: stdin, stdout, stderr, sigpipe, + // and then the listener file descriptors (in order). + fds := []uintptr{rpipe.Fd(), os.Stdout.Fd(), os.Stderr.Fd(), sigwpipe.Fd()} + + // Now add file descriptors of the sockets + serversMu.Lock() + for i, s := range servers { + fds = append(fds, s.ListenerFd()) + cdyfileGob.ListenerFds[s.Addr] = uintptr(4 + i) // 4 fds come before any of the listeners + } + serversMu.Unlock() + + // Fork the process with the current environment and file descriptors + execSpec := &syscall.ProcAttr{ + Env: os.Environ(), + Files: fds, + } + _, err = syscall.ForkExec(os.Args[0], os.Args, execSpec) + if err != nil { + return err + } + + // Feed it the Caddyfile + err = gob.NewEncoder(wpipe).Encode(cdyfileGob) + if err != nil { + return err + } + wpipe.Close() + + // Wait for child process to signal success or fail + sigwpipe.Close() // close our copy of the write end of the pipe + answer, err := ioutil.ReadAll(sigrpipe) + if err != nil || len(answer) == 0 { + log.Println("restart: child failed to answer; changes not applied") + return incompleteRestartErr + } + + // Child process is listening now; we can stop all our servers here. + return Stop() +} + +// isRestart returns whether this process is, according +// to env variables, a fork as part of a graceful restart. +func isRestart() bool { + return os.Getenv("CADDY_RESTART") == "true" +} + +// CaddyfileInput represents a Caddyfile as input +// and is simply a convenient way to implement +// the Input interface. +type CaddyfileInput struct { + Filepath string + Contents []byte +} + +// Body returns c.Contents. +func (c CaddyfileInput) Body() []byte { return c.Contents } + +// Path returns c.Filepath. +func (c CaddyfileInput) Path() string { return c.Filepath } From 821c0fab0996cbc7d8beaecd9abab4736215259a Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Mon, 26 Oct 2015 16:49:05 -0600 Subject: [PATCH 42/73] core: Refactoring POSIX-only code for build tags --- caddy/helpers.go | 63 ++++++++++++++++++---------------------- caddy/restart.go | 43 ++------------------------- caddy/restart_windows.go | 25 ++++++++++++++++ caddy/sigtrap.go | 33 +++++++++++++++++++++ caddy/sigtrap_posix.go | 26 +++++++++++++++++ 5 files changed, 114 insertions(+), 76 deletions(-) create mode 100644 caddy/restart_windows.go create mode 100644 caddy/sigtrap.go create mode 100644 caddy/sigtrap_posix.go diff --git a/caddy/helpers.go b/caddy/helpers.go index 209eb7f8..c30d7c16 100644 --- a/caddy/helpers.go +++ b/caddy/helpers.go @@ -3,52 +3,17 @@ package caddy import ( "bytes" "fmt" - "log" "os" "os/exec" - "os/signal" "runtime" "strconv" "strings" - "syscall" "github.com/mholt/caddy/caddy/letsencrypt" - "github.com/mholt/caddy/server" ) func init() { letsencrypt.OnRenew = func() error { return Restart(nil) } - - // Trap signals - go func() { - shutdown, reload := make(chan os.Signal, 1), make(chan os.Signal, 1) - signal.Notify(shutdown, os.Interrupt, os.Kill) // quit the process - signal.Notify(reload, syscall.SIGUSR1) // reload configuration - - for { - select { - case <-shutdown: - var exitCode int - - serversMu.Lock() - errs := server.ShutdownCallbacks(servers) - serversMu.Unlock() - if len(errs) > 0 { - for _, err := range errs { - log.Println(err) - } - exitCode = 1 - } - os.Exit(exitCode) - - case <-reload: - err := Restart(nil) - if err != nil { - log.Println(err) - } - } - } - }() } // isLocalhost returns true if the string looks explicitly like a localhost address. @@ -72,3 +37,31 @@ func checkFdlimit() { } } } + +// caddyfileGob maps bind address to index of the file descriptor +// in the Files array passed to the child process. It also contains +// the caddyfile contents. Used only during graceful restarts. +type caddyfileGob struct { + ListenerFds map[string]uintptr + Caddyfile []byte +} + +// isRestart returns whether this process is, according +// to env variables, a fork as part of a graceful restart. +func isRestart() bool { + return os.Getenv("CADDY_RESTART") == "true" +} + +// CaddyfileInput represents a Caddyfile as input +// and is simply a convenient way to implement +// the Input interface. +type CaddyfileInput struct { + Filepath string + Contents []byte +} + +// Body returns c.Contents. +func (c CaddyfileInput) Body() []byte { return c.Contents } + +// Path returns c.Filepath. +func (c CaddyfileInput) Path() string { return c.Filepath } diff --git a/caddy/restart.go b/caddy/restart.go index 43848bf6..7a07fbc1 100644 --- a/caddy/restart.go +++ b/caddy/restart.go @@ -1,3 +1,5 @@ +// +build !windows + package caddy import ( @@ -5,18 +7,9 @@ import ( "io/ioutil" "log" "os" - "runtime" "syscall" ) -// caddyfileGob maps bind address to index of the file descriptor -// in the Files array passed to the child process. It also contains -// the caddyfile contents. Used only during graceful restarts. -type caddyfileGob struct { - ListenerFds map[string]uintptr - Caddyfile []byte -} - // Restart restarts the entire application; gracefully with zero // downtime if on a POSIX-compatible system, or forcefully if on // Windows but with imperceptibly-short downtime. @@ -31,18 +24,6 @@ func Restart(newCaddyfile Input) error { caddyfileMu.Unlock() } - if runtime.GOOS == "windows" { - err := Stop() - if err != nil { - return err - } - err = Start(newCaddyfile) - if err != nil { - return err - } - return nil - } - if len(os.Args) == 0 { // this should never happen, but just in case... os.Args = []string{""} } @@ -110,23 +91,3 @@ func Restart(newCaddyfile Input) error { // Child process is listening now; we can stop all our servers here. return Stop() } - -// isRestart returns whether this process is, according -// to env variables, a fork as part of a graceful restart. -func isRestart() bool { - return os.Getenv("CADDY_RESTART") == "true" -} - -// CaddyfileInput represents a Caddyfile as input -// and is simply a convenient way to implement -// the Input interface. -type CaddyfileInput struct { - Filepath string - Contents []byte -} - -// Body returns c.Contents. -func (c CaddyfileInput) Body() []byte { return c.Contents } - -// Path returns c.Filepath. -func (c CaddyfileInput) Path() string { return c.Filepath } diff --git a/caddy/restart_windows.go b/caddy/restart_windows.go new file mode 100644 index 00000000..00ec94a7 --- /dev/null +++ b/caddy/restart_windows.go @@ -0,0 +1,25 @@ +package caddy + +func Restart(newCaddyfile Input) error { + if newCaddyfile == nil { + caddyfileMu.Lock() + newCaddyfile = caddyfile + caddyfileMu.Unlock() + } + + wg.Add(1) // barrier so Wait() doesn't unblock + + err := Stop() + if err != nil { + return err + } + + err = Start(newCaddyfile) + if err != nil { + return err + } + + wg.Done() // take down our barrier + + return nil +} diff --git a/caddy/sigtrap.go b/caddy/sigtrap.go new file mode 100644 index 00000000..b9cbec6a --- /dev/null +++ b/caddy/sigtrap.go @@ -0,0 +1,33 @@ +package caddy + +import ( + "log" + "os" + "os/signal" + + "github.com/mholt/caddy/server" +) + +func init() { + // Trap quit signals (cross-platform) + go func() { + shutdown := make(chan os.Signal, 1) + signal.Notify(shutdown, os.Interrupt, os.Kill) + <-shutdown + + var exitCode int + + serversMu.Lock() + errs := server.ShutdownCallbacks(servers) + serversMu.Unlock() + + if len(errs) > 0 { + for _, err := range errs { + log.Println(err) + } + exitCode = 1 + } + + os.Exit(exitCode) + }() +} diff --git a/caddy/sigtrap_posix.go b/caddy/sigtrap_posix.go new file mode 100644 index 00000000..789985ef --- /dev/null +++ b/caddy/sigtrap_posix.go @@ -0,0 +1,26 @@ +// +build !windows + +package caddy + +import ( + "log" + "os" + "os/signal" + "syscall" +) + +func init() { + // Trap POSIX-only signals + go func() { + reload := make(chan os.Signal, 1) + signal.Notify(reload, syscall.SIGUSR1) // reload configuration + + for { + <-reload + err := Restart(nil) + if err != nil { + log.Println(err) + } + } + }() +} From bb6613d0ae384574cbc638f3845bbb3250588593 Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Mon, 26 Oct 2015 17:57:32 -0600 Subject: [PATCH 43/73] core: Fix SIGUSR1 so it actually reloads config --- caddy/caddy.go | 4 ++++ caddy/helpers.go | 4 ++++ caddy/sigtrap_posix.go | 19 ++++++++++++++++++- main.go | 2 ++ 4 files changed, 28 insertions(+), 1 deletion(-) diff --git a/caddy/caddy.go b/caddy/caddy.go index b80450ee..9383e386 100644 --- a/caddy/caddy.go +++ b/caddy/caddy.go @@ -319,4 +319,8 @@ type Input interface { // Gets the path to the origin file Path() string + + // IsFile returns true if the original input was a file on the file system + // that could be loaded again later if requested. + IsFile() bool } diff --git a/caddy/helpers.go b/caddy/helpers.go index c30d7c16..d8f40970 100644 --- a/caddy/helpers.go +++ b/caddy/helpers.go @@ -58,6 +58,7 @@ func isRestart() bool { type CaddyfileInput struct { Filepath string Contents []byte + RealFile bool } // Body returns c.Contents. @@ -65,3 +66,6 @@ func (c CaddyfileInput) Body() []byte { return c.Contents } // Path returns c.Filepath. func (c CaddyfileInput) Path() string { return c.Filepath } + +// Path returns true if the original input was a real file on the file system. +func (c CaddyfileInput) IsFile() bool { return c.RealFile } diff --git a/caddy/sigtrap_posix.go b/caddy/sigtrap_posix.go index 789985ef..122adf2c 100644 --- a/caddy/sigtrap_posix.go +++ b/caddy/sigtrap_posix.go @@ -3,6 +3,7 @@ package caddy import ( + "io/ioutil" "log" "os" "os/signal" @@ -17,7 +18,23 @@ func init() { for { <-reload - err := Restart(nil) + + var updatedCaddyfile Input + + caddyfileMu.Lock() + if caddyfile.IsFile() { + body, err := ioutil.ReadFile(caddyfile.Path()) + if err == nil { + caddyfile = CaddyfileInput{ + Filepath: caddyfile.Path(), + Contents: body, + RealFile: true, + } + } + } + caddyfileMu.Unlock() + + err := Restart(updatedCaddyfile) if err != nil { log.Println(err) } diff --git a/main.go b/main.go index 8e4bffc2..15d39de5 100644 --- a/main.go +++ b/main.go @@ -92,6 +92,7 @@ func loadCaddyfile() (caddy.Input, error) { return caddy.CaddyfileInput{ Contents: contents, Filepath: conf, + RealFile: true, }, nil } @@ -115,6 +116,7 @@ func loadCaddyfile() (caddy.Input, error) { return caddy.CaddyfileInput{ Contents: contents, Filepath: caddy.DefaultConfigFile, + RealFile: true, }, nil } From c487b702a22c1a28bc0380b3e45a314d586ce0ef Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Tue, 27 Oct 2015 00:05:22 -0600 Subject: [PATCH 44/73] Little cleanup --- caddy/caddy.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/caddy/caddy.go b/caddy/caddy.go index 9383e386..f6976ea0 100644 --- a/caddy/caddy.go +++ b/caddy/caddy.go @@ -149,9 +149,9 @@ func Start(cdyfile Input) error { // Tell parent process that we got this if isRestart() { - file := os.NewFile(3, "") - file.Write([]byte("success")) - file.Close() + ppipe := os.NewFile(3, "") // parent is listening on pipe at index 3 + ppipe.Write([]byte("success")) + ppipe.Close() } return nil From ee5c842c7d5abb5613b536deb749296beb078bb9 Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Tue, 27 Oct 2015 00:07:22 -0600 Subject: [PATCH 45/73] Code to convert between JSON and Caddyfile This will be used by the API so clients have an easier time manipulating the configuration --- caddy/caddyfile/json.go | 153 +++++++++++++++++++++++++++++++++++ caddy/caddyfile/json_test.go | 62 ++++++++++++++ caddy/config.go | 2 +- caddy/parse/parse.go | 8 +- caddy/parse/parsing.go | 11 ++- caddy/parse/parsing_test.go | 2 +- 6 files changed, 229 insertions(+), 9 deletions(-) create mode 100644 caddy/caddyfile/json.go create mode 100644 caddy/caddyfile/json_test.go diff --git a/caddy/caddyfile/json.go b/caddy/caddyfile/json.go new file mode 100644 index 00000000..20b36bcc --- /dev/null +++ b/caddy/caddyfile/json.go @@ -0,0 +1,153 @@ +package caddyfile + +import ( + "bytes" + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/mholt/caddy/caddy/parse" +) + +const filename = "Caddyfile" + +// ToJSON converts caddyfile to its JSON representation. +func ToJSON(caddyfile []byte) ([]byte, error) { + var j Caddyfile + + serverBlocks, err := parse.ServerBlocks(filename, bytes.NewReader(caddyfile), false) + if err != nil { + return nil, err + } + + for _, sb := range serverBlocks { + block := ServerBlock{Body: make(map[string]interface{})} + + for _, host := range sb.HostList() { + block.Hosts = append(block.Hosts, host) + } + + for dir, tokens := range sb.Tokens { + disp := parse.NewDispenserTokens(filename, tokens) + disp.Next() // the first token is the directive; skip it + block.Body[dir] = constructLine(disp) + } + + j = append(j, block) + } + + result, err := json.Marshal(j) + if err != nil { + return nil, err + } + + return result, nil +} + +// constructLine transforms tokens into a JSON-encodable structure; +// but only one line at a time, to be used at the top-level of +// a server block only (where the first token on each line is a +// directive) - not to be used at any other nesting level. +func constructLine(d parse.Dispenser) interface{} { + var args []interface{} + + all := d.RemainingArgs() + for _, arg := range all { + args = append(args, arg) + } + + d.Next() + if d.Val() == "{" { + args = append(args, constructBlock(d)) + } + + return args +} + +// constructBlock recursively processes tokens into a +// JSON-encodable structure. +func constructBlock(d parse.Dispenser) interface{} { + block := make(map[string]interface{}) + + for d.Next() { + if d.Val() == "}" { + break + } + + dir := d.Val() + all := d.RemainingArgs() + + var args []interface{} + for _, arg := range all { + args = append(args, arg) + } + if d.Val() == "{" { + args = append(args, constructBlock(d)) + } + + block[dir] = args + } + + return block +} + +// FromJSON converts JSON-encoded jsonBytes to Caddyfile text +func FromJSON(jsonBytes []byte) ([]byte, error) { + var j Caddyfile + var result string + + err := json.Unmarshal(jsonBytes, &j) + if err != nil { + return nil, err + } + + for _, sb := range j { + for i, host := range sb.Hosts { + if i > 0 { + result += ", " + } + result += host + } + result += jsonToText(sb.Body, 1) + } + + return []byte(result), nil +} + +// jsonToText recursively transforms a scope of JSON into plain +// Caddyfile text. +func jsonToText(scope interface{}, depth int) string { + var result string + + switch val := scope.(type) { + case string: + result += " " + val + case int: + result += " " + strconv.Itoa(val) + case float64: + result += " " + fmt.Sprintf("%f", val) + case bool: + result += " " + fmt.Sprintf("%t", val) + case map[string]interface{}: + result += " {\n" + for param, args := range val { + result += strings.Repeat("\t", depth) + param + result += jsonToText(args, depth+1) + "\n" + } + result += strings.Repeat("\t", depth-1) + "}" + case []interface{}: + for _, v := range val { + result += jsonToText(v, depth) + } + } + + return result +} + +type Caddyfile []ServerBlock + +type ServerBlock struct { + Hosts []string `json:"hosts"` + Body map[string]interface{} `json:"body"` +} diff --git a/caddy/caddyfile/json_test.go b/caddy/caddyfile/json_test.go new file mode 100644 index 00000000..2d94d604 --- /dev/null +++ b/caddy/caddyfile/json_test.go @@ -0,0 +1,62 @@ +package caddyfile + +import "testing" + +var tests = []struct { + caddyfile, json string +}{ + { // 0 + caddyfile: `foo: { + root /bar +}`, + json: `[{"hosts":["foo:"],"body":{"root":["/bar"]}}]`, + }, + { // 1 + caddyfile: `host1:, host2: { + dir { + def + } +}`, + json: `[{"hosts":["host1:","host2:"],"body":{"dir":[{"def":null}]}}]`, + }, + { // 2 + caddyfile: `host1:, host2: { + dir abc { + def ghi + jklmnop + } +}`, + json: `[{"hosts":["host1:","host2:"],"body":{"dir":["abc",{"def":["ghi"],"jklmnop":null}]}}]`, + }, + { // 3 + caddyfile: `host1:1234, host2:5678 { + dir abc { + } +}`, + json: `[{"hosts":["host1:1234","host2:5678"],"body":{"dir":["abc",{}]}}]`, + }, +} + +func TestToJSON(t *testing.T) { + for i, test := range tests { + output, err := ToJSON([]byte(test.caddyfile)) + if err != nil { + t.Errorf("Test %d: %v", i, err) + } + if string(output) != test.json { + t.Errorf("Test %d\nExpected:\n'%s'\nActual:\n'%s'", i, test.json, string(output)) + } + } +} + +func TestFromJSON(t *testing.T) { + for i, test := range tests { + output, err := FromJSON([]byte(test.json)) + if err != nil { + t.Errorf("Test %d: %v", i, err) + } + if string(output) != test.caddyfile { + t.Errorf("Test %d\nExpected:\n'%s'\nActual:\n'%s'", i, test.caddyfile, string(output)) + } + } +} diff --git a/caddy/config.go b/caddy/config.go index dac65784..5688a6db 100644 --- a/caddy/config.go +++ b/caddy/config.go @@ -29,7 +29,7 @@ func Load(filename string, input io.Reader) (Group, error) { flags := log.Flags() log.SetFlags(0) - serverBlocks, err := parse.ServerBlocks(filename, input) + serverBlocks, err := parse.ServerBlocks(filename, input, true) if err != nil { return nil, err } diff --git a/caddy/parse/parse.go b/caddy/parse/parse.go index b44041d4..84043e60 100644 --- a/caddy/parse/parse.go +++ b/caddy/parse/parse.go @@ -5,9 +5,11 @@ import "io" // ServerBlocks parses the input just enough to organize tokens, // in order, by server block. No further parsing is performed. -// Server blocks are returned in the order in which they appear. -func ServerBlocks(filename string, input io.Reader) ([]serverBlock, error) { - p := parser{Dispenser: NewDispenser(filename, input)} +// If checkDirectives is true, only valid directives will be allowed +// otherwise we consider it a parse error. Server blocks are returned +// in the order in which they appear. +func ServerBlocks(filename string, input io.Reader, checkDirectives bool) ([]serverBlock, error) { + p := parser{Dispenser: NewDispenser(filename, input), checkDirectives: checkDirectives} blocks, err := p.parseAll() return blocks, err } diff --git a/caddy/parse/parsing.go b/caddy/parse/parsing.go index 59455391..b24b46ab 100644 --- a/caddy/parse/parsing.go +++ b/caddy/parse/parsing.go @@ -9,8 +9,9 @@ import ( type parser struct { Dispenser - block serverBlock // current server block being parsed - eof bool // if we encounter a valid EOF in a hard place + block serverBlock // current server block being parsed + eof bool // if we encounter a valid EOF in a hard place + checkDirectives bool // if true, directives must be known } func (p *parser) parseAll() ([]serverBlock, error) { @@ -220,8 +221,10 @@ func (p *parser) directive() error { dir := p.Val() nesting := 0 - if _, ok := ValidDirectives[dir]; !ok { - return p.Errf("Unknown directive '%s'", dir) + if p.checkDirectives { + if _, ok := ValidDirectives[dir]; !ok { + return p.Errf("Unknown directive '%s'", dir) + } } // The directive itself is appended as a relevant token diff --git a/caddy/parse/parsing_test.go b/caddy/parse/parsing_test.go index c8a7ef0b..afd5870f 100644 --- a/caddy/parse/parsing_test.go +++ b/caddy/parse/parsing_test.go @@ -375,6 +375,6 @@ func setupParseTests() { func testParser(input string) parser { buf := strings.NewReader(input) - p := parser{Dispenser: NewDispenser("Test", buf)} + p := parser{Dispenser: NewDispenser("Test", buf), checkDirectives: true} return p } From 0f19df8a81e01bb53bffa16833bf816ea3a4f14d Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Tue, 27 Oct 2015 00:43:24 -0600 Subject: [PATCH 46/73] Keep tests deterministic --- caddy/caddyfile/json_test.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/caddy/caddyfile/json_test.go b/caddy/caddyfile/json_test.go index 2d94d604..11e1b1f4 100644 --- a/caddy/caddyfile/json_test.go +++ b/caddy/caddyfile/json_test.go @@ -23,10 +23,9 @@ var tests = []struct { caddyfile: `host1:, host2: { dir abc { def ghi - jklmnop } }`, - json: `[{"hosts":["host1:","host2:"],"body":{"dir":["abc",{"def":["ghi"],"jklmnop":null}]}}]`, + json: `[{"hosts":["host1:","host2:"],"body":{"dir":["abc",{"def":["ghi"]}]}}]`, }, { // 3 caddyfile: `host1:1234, host2:5678 { From a6ea1e6b55b840dc3c55dac4dc2f97ae0bfcc64a Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Tue, 27 Oct 2015 12:52:58 -0600 Subject: [PATCH 47/73] letsencrypt: -ca flag to customize CA server --- caddy/letsencrypt/letsencrypt.go | 10 ++++------ main.go | 8 ++++++-- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/caddy/letsencrypt/letsencrypt.go b/caddy/letsencrypt/letsencrypt.go index a7aef7e8..093a4eb6 100644 --- a/caddy/letsencrypt/letsencrypt.go +++ b/caddy/letsencrypt/letsencrypt.go @@ -158,7 +158,7 @@ func newClient(leEmail string) (*acme.Client, error) { } // The client facilitates our communication with the CA server. - client := acme.NewClient(caURL, &leUser, rsaKeySizeToUse, exposePort) + client := acme.NewClient(CAUrl, &leUser, rsaKeySizeToUse, exposePort) // If not registered, the user must register an account with the CA // and agree to terms @@ -331,15 +331,13 @@ var ( // Whether user has agreed to the Let's Encrypt SA Agreed bool + + // The base URL to the CA's ACME endpoint + CAUrl string ) // Some essential values related to the Let's Encrypt process const ( - // The base URL to the Let's Encrypt CA - // TODO: Staging API URL is: https://acme-staging.api.letsencrypt.org - // TODO: Production endpoint is: https://acme-v01.api.letsencrypt.org - caURL = "http://192.168.99.100:4000" - // The port to expose to the CA server for Simple HTTP Challenge exposePort = "5001" diff --git a/main.go b/main.go index 15d39de5..aa3ed0d8 100644 --- a/main.go +++ b/main.go @@ -29,16 +29,20 @@ const ( func init() { flag.StringVar(&conf, "conf", "", "Configuration file to use (default="+caddy.DefaultConfigFile+")") - flag.BoolVar(&caddy.HTTP2, "http2", true, "Enable HTTP/2 support") // TODO: temporary flag until http2 merged into std lib + flag.BoolVar(&caddy.HTTP2, "http2", true, "HTTP/2 support") // TODO: temporary flag until http2 merged into std lib flag.BoolVar(&caddy.Quiet, "quiet", false, "Quiet mode (no initialization output)") flag.StringVar(&cpu, "cpu", "100%", "CPU cap") flag.StringVar(&caddy.Root, "root", caddy.DefaultRoot, "Root path to default site") flag.StringVar(&caddy.Host, "host", caddy.DefaultHost, "Default host") flag.StringVar(&caddy.Port, "port", caddy.DefaultPort, "Default port") flag.BoolVar(&version, "version", false, "Show version") + // TODO: Boulder dev URL is: http://192.168.99.100:4000 + // TODO: Staging API URL is: https://acme-staging.api.letsencrypt.org + // TODO: Production endpoint is: https://acme-v01.api.letsencrypt.org + flag.StringVar(&letsencrypt.CAUrl, "ca", "https://acme-staging.api.letsencrypt.org", "Certificate authority ACME server") flag.BoolVar(&letsencrypt.Agreed, "agree", false, "Agree to Let's Encrypt Subscriber Agreement") flag.StringVar(&letsencrypt.DefaultEmail, "email", "", "Default email address to use for Let's Encrypt transactions") - flag.StringVar(&revoke, "revoke", "", "Hostname for which to revoke its certificate") + flag.StringVar(&revoke, "revoke", "", "Hostname for which to revoke the certificate") } func main() { From 362ead276098d8f4cdf433a2dba6753962081680 Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Tue, 27 Oct 2015 12:53:31 -0600 Subject: [PATCH 48/73] Minor test improvements --- caddy/assets/path_test.go | 12 ++++++++++++ caddy/letsencrypt/crypto_test.go | 4 ++-- 2 files changed, 14 insertions(+), 2 deletions(-) create mode 100644 caddy/assets/path_test.go diff --git a/caddy/assets/path_test.go b/caddy/assets/path_test.go new file mode 100644 index 00000000..374f813a --- /dev/null +++ b/caddy/assets/path_test.go @@ -0,0 +1,12 @@ +package assets + +import ( + "strings" + "testing" +) + +func TestPath(t *testing.T) { + if actual := Path(); !strings.HasSuffix(actual, ".caddy") { + t.Errorf("Expected path to be a .caddy folder, got: %v", actual) + } +} diff --git a/caddy/letsencrypt/crypto_test.go b/caddy/letsencrypt/crypto_test.go index 938778a8..7f791a6c 100644 --- a/caddy/letsencrypt/crypto_test.go +++ b/caddy/letsencrypt/crypto_test.go @@ -10,14 +10,14 @@ import ( ) func init() { - rsaKeySizeToUse = 128 // makes tests faster + rsaKeySizeToUse = 128 // make tests faster; small key size OK for testing } func TestSaveAndLoadRSAPrivateKey(t *testing.T) { keyFile := "test.key" defer os.Remove(keyFile) - privateKey, err := rsa.GenerateKey(rand.Reader, 128) // small key size is OK for testing + privateKey, err := rsa.GenerateKey(rand.Reader, rsaKeySizeToUse) if err != nil { t.Fatal(err) } From 8548641dc1225d2423214fa628f1c2b9af0ff457 Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Tue, 27 Oct 2015 13:02:47 -0600 Subject: [PATCH 49/73] letsencrypt: Check for errors --- caddy/letsencrypt/letsencrypt.go | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/caddy/letsencrypt/letsencrypt.go b/caddy/letsencrypt/letsencrypt.go index 3edd2b92..ca9a1b89 100644 --- a/caddy/letsencrypt/letsencrypt.go +++ b/caddy/letsencrypt/letsencrypt.go @@ -39,7 +39,7 @@ var OnRenew func() error func Activate(configs []server.Config) ([]server.Config, error) { // First identify and configure any elligible hosts for which // we already have certs and keys in storage from last time. - configLen := len(configs) // avoid infinite loop since this loop appends to the slice + configLen := len(configs) // avoid infinite loop since this loop appends plaintext to the slice for i := 0; i < configLen; i++ { if existingCertAndKey(configs[i].Host) && configs[i].TLS.LetsEncryptEmail != "off" { configs = autoConfigure(&configs[i], configs) @@ -238,9 +238,14 @@ func saveCertsAndKeys(certificates []acme.CertificateResource) error { // autoConfigure enables TLS on cfg and appends, if necessary, a new config // to allConfigs that redirects plaintext HTTP to its new HTTPS counterpart. func autoConfigure(cfg *server.Config, allConfigs []server.Config) []server.Config { - bundleBytes, _ := ioutil.ReadFile(storage.SiteCertFile(cfg.Host)) - ocsp, _ := acme.GetOCSPForCert(bundleBytes) - cfg.TLS.OCSPStaple = ocsp + bundleBytes, err := ioutil.ReadFile(storage.SiteCertFile(cfg.Host)) + // TODO: Handle these errors better + if err == nil { + ocsp, err := acme.GetOCSPForCert(bundleBytes) + if err == nil { + cfg.TLS.OCSPStaple = ocsp + } + } cfg.TLS.Certificate = storage.SiteCertFile(cfg.Host) cfg.TLS.Key = storage.SiteKeyFile(cfg.Host) cfg.TLS.Enabled = true From b67543f81ce5ffd9e6bd141e010a2ee58c44f357 Mon Sep 17 00:00:00 2001 From: xenolf Date: Wed, 28 Oct 2015 16:35:19 +0100 Subject: [PATCH 50/73] Track the latest lego OCSP changes --- caddy/letsencrypt/letsencrypt.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/caddy/letsencrypt/letsencrypt.go b/caddy/letsencrypt/letsencrypt.go index ca9a1b89..8037985a 100644 --- a/caddy/letsencrypt/letsencrypt.go +++ b/caddy/letsencrypt/letsencrypt.go @@ -241,8 +241,8 @@ func autoConfigure(cfg *server.Config, allConfigs []server.Config) []server.Conf bundleBytes, err := ioutil.ReadFile(storage.SiteCertFile(cfg.Host)) // TODO: Handle these errors better if err == nil { - ocsp, err := acme.GetOCSPForCert(bundleBytes) - if err == nil { + ocsp, status, err := acme.GetOCSPForCert(bundleBytes) + if err == nil && status == acme.OCSPGood { cfg.TLS.OCSPStaple = ocsp } } From 1818b1ea627b46f5ed0bfec18a148d7ab84eecd6 Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Wed, 28 Oct 2015 18:12:07 -0600 Subject: [PATCH 51/73] letsencrypt: Better error handling, prompt user for SA --- caddy/letsencrypt/letsencrypt.go | 19 ++++++++++++++----- caddy/letsencrypt/renew.go | 3 ++- caddy/letsencrypt/user.go | 23 +++++++++++++++++++++++ 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/caddy/letsencrypt/letsencrypt.go b/caddy/letsencrypt/letsencrypt.go index 8037985a..4af30e35 100644 --- a/caddy/letsencrypt/letsencrypt.go +++ b/caddy/letsencrypt/letsencrypt.go @@ -62,19 +62,19 @@ func Activate(configs []server.Config) ([]server.Config, error) { // make client to service this email address with CA server client, err := newClient(leEmail) if err != nil { - return configs, err + return configs, errors.New("error creating client: " + err.Error()) } // client is ready, so let's get free, trusted SSL certificates! yeah! certificates, err := obtainCertificates(client, serverConfigs) if err != nil { - return configs, err + return configs, errors.New("error obtaining cert: " + err.Error()) } // ... that's it. save the certs, keys, and metadata files to disk err = saveCertsAndKeys(certificates) if err != nil { - return configs, err + return configs, errors.New("error saving assets: " + err.Error()) } // it all comes down to this: turning TLS on for all the configs @@ -158,7 +158,10 @@ func newClient(leEmail string) (*acme.Client, error) { } // The client facilitates our communication with the CA server. - client := acme.NewClient(CAUrl, &leUser, rsaKeySizeToUse, exposePort) + client, err := acme.NewClient(CAUrl, &leUser, rsaKeySizeToUse, exposePort) + if err != nil { + return nil, err + } // If not registered, the user must register an account with the CA // and agree to terms @@ -169,7 +172,13 @@ func newClient(leEmail string) (*acme.Client, error) { } leUser.Registration = reg - // TODO: we can just do the agreement once: when registering, right? + if !Agreed && reg.TosURL == "" { + Agreed = promptUserAgreement("", false) // TODO + } + if !Agreed && reg.TosURL == "" { + return nil, errors.New("user must agree to terms") + } + err = client.AgreeToTOS() if err != nil { saveUser(leUser) // TODO: Might as well try, right? Error check? diff --git a/caddy/letsencrypt/renew.go b/caddy/letsencrypt/renew.go index a00eb015..db7345f0 100644 --- a/caddy/letsencrypt/renew.go +++ b/caddy/letsencrypt/renew.go @@ -34,7 +34,8 @@ func keepCertificatesRenewed(configs []server.Config) { // checkCertificateRenewal loops through all configured // sites and looks for certificates to renew. Nothing is mutated // through this function. The changes happen directly on disk. -// It returns the number of certificates renewed and +// It returns the number of certificates renewed and any errors +// that occurred. func processCertificateRenewal(configs []server.Config) (int, []error) { log.Print("[INFO] Processing certificate renewals...") var errs []error diff --git a/caddy/letsencrypt/user.go b/caddy/letsencrypt/user.go index 752cc510..ff4d6acb 100644 --- a/caddy/letsencrypt/user.go +++ b/caddy/letsencrypt/user.go @@ -156,6 +156,29 @@ func getEmail(cfg server.Config) string { return strings.TrimSpace(leEmail) } +// 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): ") + } + + reader := bufio.NewReader(stdin) // TODO/BUG: This doesn't work when Caddyfile is piped into caddy + answer, err := reader.ReadString('\n') + if err != nil { + return false + } + answer = strings.ToLower(strings.TrimSpace(answer)) + + return answer == "y" || answer == "yes" +} + // stdin is used to read the user's input if prompted; // this is changed by tests during tests. var stdin = io.ReadWriter(os.Stdin) From 6762df415c01bde2a5a9e92d5fe1997198656366 Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Wed, 28 Oct 2015 22:54:27 -0600 Subject: [PATCH 52/73] Clean up leaking goroutines and safer Start()/Stop() --- caddy/caddy.go | 22 ++++++++- caddy/config.go | 24 +++------- caddy/letsencrypt/letsencrypt.go | 29 +++++++++++- caddy/letsencrypt/{renew.go => maintain.go} | 50 ++++++++++++++------- caddy/restart.go | 2 +- server/server.go | 5 ++- 6 files changed, 90 insertions(+), 42 deletions(-) rename caddy/letsencrypt/{renew.go => maintain.go} (69%) diff --git a/caddy/caddy.go b/caddy/caddy.go index f6976ea0..022cbb90 100644 --- a/caddy/caddy.go +++ b/caddy/caddy.go @@ -26,6 +26,7 @@ import ( "strings" "sync" + "github.com/mholt/caddy/caddy/letsencrypt" "github.com/mholt/caddy/server" ) @@ -90,6 +91,8 @@ const ( // In any case, an error is returned if Caddy could not be // started. func Start(cdyfile Input) error { + // TODO: What if already started -- is that an error? + var err error // Input must never be nil; try to load something @@ -104,7 +107,20 @@ func Start(cdyfile Input) error { caddyfile = cdyfile caddyfileMu.Unlock() - groupings, err := Load(path.Base(cdyfile.Path()), bytes.NewReader(cdyfile.Body())) + // load the server configs + configs, err := load(path.Base(cdyfile.Path()), bytes.NewReader(cdyfile.Body())) + if err != nil { + return err + } + + // secure all the things + configs, err = letsencrypt.Activate(configs) + if err != nil { + return err + } + + // group virtualhosts by address + groupings, err := arrangeBindings(configs) if err != nil { return err } @@ -217,11 +233,15 @@ func startServers(groupings Group) error { // Stop stops all servers. It blocks until they are all stopped. func Stop() error { + letsencrypt.Deactivate() + serversMu.Lock() for _, s := range servers { s.Stop() // TODO: error checking/reporting? } + servers = []*server.Server{} // don't reuse servers serversMu.Unlock() + return nil } diff --git a/caddy/config.go b/caddy/config.go index dac65784..bc9ec603 100644 --- a/caddy/config.go +++ b/caddy/config.go @@ -7,7 +7,6 @@ import ( "net" "sync" - "github.com/mholt/caddy/caddy/letsencrypt" "github.com/mholt/caddy/caddy/parse" "github.com/mholt/caddy/caddy/setup" "github.com/mholt/caddy/middleware" @@ -20,9 +19,9 @@ const ( DefaultConfigFile = "Caddyfile" ) -// Load reads input (named filename) and parses it, returning server -// configurations grouped by listening address. -func Load(filename string, input io.Reader) (Group, error) { +// load reads input (named filename) and parses it, returning the +// server configurations in the order they appeared in the input. +func load(filename string, input io.Reader) ([]server.Config, error) { var configs []server.Config // turn off timestamp for parsing @@ -34,7 +33,7 @@ func Load(filename string, input io.Reader) (Group, error) { return nil, err } if len(serverBlocks) == 0 { - return Default() + return []server.Config{NewDefault()}, nil } // Each server block represents similar hosts/addresses. @@ -101,14 +100,7 @@ func Load(filename string, input io.Reader) (Group, error) { // restore logging settings log.SetFlags(flags) - // secure all the things - configs, err = letsencrypt.Activate(configs) - if err != nil { - return nil, err - } - - // group by address/virtualhosts - return arrangeBindings(configs) + return configs, nil } // makeOnces makes a map of directive name to sync.Once @@ -271,12 +263,6 @@ func NewDefault() server.Config { } } -// Default obtains a default config and arranges -// bindings so it's ready to use. -func Default() (Group, error) { - return arrangeBindings([]server.Config{NewDefault()}) -} - // These defaults are configurable through the command line var ( // Site root diff --git a/caddy/letsencrypt/letsencrypt.go b/caddy/letsencrypt/letsencrypt.go index 4af30e35..3ae904cf 100644 --- a/caddy/letsencrypt/letsencrypt.go +++ b/caddy/letsencrypt/letsencrypt.go @@ -36,6 +36,9 @@ var OnRenew func() error // argument. If absent, it will use the most recent email // address from last time. If there isn't one, the user // will be prompted. If the user leaves email blank, . +// +// Also note that calling this function activates asset +// management automatically, which . func Activate(configs []server.Config) ([]server.Config, error) { // First identify and configure any elligible hosts for which // we already have certs and keys in storage from last time. @@ -47,7 +50,7 @@ func Activate(configs []server.Config) ([]server.Config, error) { } // First renew any existing certificates that need it - processCertificateRenewal(configs) + renewCertificates(configs) // Group configs by LE email address; this will help us // reduce round-trips when getting the certs. @@ -83,11 +86,26 @@ func Activate(configs []server.Config) ([]server.Config, error) { } } - go keepCertificatesRenewed(configs) + stopChan = make(chan struct{}) + go maintainAssets(configs, stopChan) return configs, nil } +// Deactivate cleans up long-term, in-memory resources +// allocated by calling Activate(). Essentially, it stops +// the asset maintainer from running, meaning that certificates +// will not be renewed, OCSP staples will not be updated, etc. +func Deactivate() (err error) { + defer func() { + if rec := recover(); rec != nil { + err = errors.New("already deactivated") + } + }() + close(stopChan) + return +} + // groupConfigsByEmail groups configs by the Let's Encrypt email address // associated to them or to the default Let's Encrypt email address. If the // default email is not available, the user will be prompted to provide one. @@ -360,6 +378,9 @@ const ( // How often to check certificates for renewal renewInterval = 24 * time.Hour + + // How often to update OCSP stapling + ocspInterval = 1 * time.Hour ) // KeySize represents the length of a key in bits. @@ -377,3 +398,7 @@ const ( // This shouldn't need to change except for in tests; // the size can be drastically reduced for speed. var rsaKeySizeToUse = RSA_2048 + +// stopChan is used to signal the maintenance goroutine +// to terminate. +var stopChan chan struct{} diff --git a/caddy/letsencrypt/renew.go b/caddy/letsencrypt/maintain.go similarity index 69% rename from caddy/letsencrypt/renew.go rename to caddy/letsencrypt/maintain.go index db7345f0..bca1e3d8 100644 --- a/caddy/letsencrypt/renew.go +++ b/caddy/letsencrypt/maintain.go @@ -10,33 +10,49 @@ import ( "github.com/xenolf/lego/acme" ) -// keepCertificatesRenewed is a permanently-blocking function +// maintainAssets is a permanently-blocking function // that loops indefinitely and, on a regular schedule, checks // certificates for expiration and initiates a renewal of certs -// that are expiring soon. -func keepCertificatesRenewed(configs []server.Config) { - ticker := time.Tick(renewInterval) - for range ticker { - if n, errs := processCertificateRenewal(configs); len(errs) > 0 { - for _, err := range errs { - log.Printf("[ERROR] cert renewal: %v\n", err) - } - if n > 0 && OnRenew != nil { - err := OnRenew() - if err != nil { - log.Printf("[ERROR] onrenew callback: %v\n", err) +// that are expiring soon. It also updates OCSP stapling and +// performs other maintenance of assets. +// +// You must pass in the server configs to maintain and the channel +// which you'll close when maintenance should stop, to allow this +// goroutine to clean up after itself. +func maintainAssets(configs []server.Config, stopChan chan struct{}) { + renewalTicker := time.NewTicker(renewInterval) + ocspTicker := time.NewTicker(ocspInterval) + + for { + select { + case <-renewalTicker.C: + if n, errs := renewCertificates(configs); len(errs) > 0 { + for _, err := range errs { + log.Printf("[ERROR] cert renewal: %v\n", err) + } + if n > 0 && OnRenew != nil { + err := OnRenew() + if err != nil { + log.Printf("[ERROR] onrenew callback: %v\n", err) + } } } + case <-ocspTicker.C: + // TODO: Update OCSP + case <-stopChan: + renewalTicker.Stop() + ocspTicker.Stop() + return } } } -// checkCertificateRenewal loops through all configured -// sites and looks for certificates to renew. Nothing is mutated +// renewCertificates loops through all configured site and +// looks for certificates to renew. Nothing is mutated // through this function. The changes happen directly on disk. // It returns the number of certificates renewed and any errors -// that occurred. -func processCertificateRenewal(configs []server.Config) (int, []error) { +// that occurred. It only performs a renewal if necessary. +func renewCertificates(configs []server.Config) (int, []error) { log.Print("[INFO] Processing certificate renewals...") var errs []error var n int diff --git a/caddy/restart.go b/caddy/restart.go index 7a07fbc1..1c61c5a0 100644 --- a/caddy/restart.go +++ b/caddy/restart.go @@ -81,7 +81,7 @@ func Restart(newCaddyfile Input) error { wpipe.Close() // Wait for child process to signal success or fail - sigwpipe.Close() // close our copy of the write end of the pipe + sigwpipe.Close() // close our copy of the write end of the pipe or we might be stuck answer, err := ioutil.ReadAll(sigrpipe) if err != nil || len(answer) == 0 { log.Println("restart: child failed to answer; changes not applied") diff --git a/server/server.go b/server/server.go index 09cdbe58..9e7bcb38 100644 --- a/server/server.go +++ b/server/server.go @@ -66,6 +66,7 @@ func New(addr string, configs []Config) (*Server, error) { // into sync.WaitGroup.Wait() - basically, an add // with a positive delta must be guaranteed to // occur before Wait() is called on the wg. + // In a way, this kind of acts as a safety barrier. s.httpWg.Add(1) // Set up each virtualhost @@ -228,12 +229,12 @@ func (s *Server) Stop() error { // Wait for remaining connections to finish or // force them all to close after timeout select { - case <-time.After(5 * time.Second): // TODO: configurable? + case <-time.After(5 * time.Second): // TODO: make configurable? case <-done: } } - // Close the listener now; this stops the server and + // Close the listener now; this stops the server without delay s.listenerMu.Lock() err := s.listener.Close() s.listenerMu.Unlock() From 30c949085cad82d07562ca3403a22513b8fcd440 Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Wed, 28 Oct 2015 23:43:26 -0600 Subject: [PATCH 53/73] letsencrypt: Stubbed out OCSP staple updates OCSP status is checked at a regular interval, and if the OCSP status changes for any of the certificates, the change callback is executed (restarts the server, updating the OCSP staple). --- caddy/helpers.go | 2 +- caddy/letsencrypt/letsencrypt.go | 16 +++++++++------- caddy/letsencrypt/maintain.go | 25 +++++++++++++++++++++---- 3 files changed, 31 insertions(+), 12 deletions(-) diff --git a/caddy/helpers.go b/caddy/helpers.go index d8f40970..a22e7f5c 100644 --- a/caddy/helpers.go +++ b/caddy/helpers.go @@ -13,7 +13,7 @@ import ( ) func init() { - letsencrypt.OnRenew = func() error { return Restart(nil) } + letsencrypt.OnChange = func() error { return Restart(nil) } } // isLocalhost returns true if the string looks explicitly like a localhost address. diff --git a/caddy/letsencrypt/letsencrypt.go b/caddy/letsencrypt/letsencrypt.go index 3ae904cf..4d58bebb 100644 --- a/caddy/letsencrypt/letsencrypt.go +++ b/caddy/letsencrypt/letsencrypt.go @@ -18,12 +18,6 @@ import ( "github.com/xenolf/lego/acme" ) -// OnRenew is the function that will be used to restart -// the application or the part of the application that uses -// the certificates maintained by this package. When at least -// one certificate is renewed, this function will be called. -var OnRenew func() error - // Activate sets up TLS for each server config in configs // as needed. It only skips the config if the cert and key // are already provided or if plaintext http is explicitly @@ -40,7 +34,9 @@ var OnRenew func() error // Also note that calling this function activates asset // management automatically, which . func Activate(configs []server.Config) ([]server.Config, error) { - // First identify and configure any elligible hosts for which + // TODO: Is multiple activation (before a deactivation) an error? + + // First identify and configure any eligible hosts for which // we already have certs and keys in storage from last time. configLen := len(configs) // avoid infinite loop since this loop appends plaintext to the slice for i := 0; i < configLen; i++ { @@ -269,6 +265,7 @@ func autoConfigure(cfg *server.Config, allConfigs []server.Config) []server.Conf // TODO: Handle these errors better if err == nil { ocsp, status, err := acme.GetOCSPForCert(bundleBytes) + ocspStatus[&bundleBytes] = status if err == nil && status == acme.OCSPGood { cfg.TLS.OCSPStaple = ocsp } @@ -402,3 +399,8 @@ var rsaKeySizeToUse = RSA_2048 // stopChan is used to signal the maintenance goroutine // to terminate. var stopChan chan struct{} + +// ocspStatus maps certificate bundle to OCSP status at start. +// It is used during regular OCSP checks to see if the OCSP +// status has changed. +var ocspStatus = make(map[*[]byte]int) diff --git a/caddy/letsencrypt/maintain.go b/caddy/letsencrypt/maintain.go index bca1e3d8..62af5e1c 100644 --- a/caddy/letsencrypt/maintain.go +++ b/caddy/letsencrypt/maintain.go @@ -10,6 +10,13 @@ import ( "github.com/xenolf/lego/acme" ) +// OnChange is a callback function that will be used to restart +// the application or the part of the application that uses +// the certificates maintained by this package. When at least +// one certificate is renewed or an OCSP status changes, this +// function will be called. +var OnChange func() error + // maintainAssets is a permanently-blocking function // that loops indefinitely and, on a regular schedule, checks // certificates for expiration and initiates a renewal of certs @@ -30,15 +37,25 @@ func maintainAssets(configs []server.Config, stopChan chan struct{}) { for _, err := range errs { log.Printf("[ERROR] cert renewal: %v\n", err) } - if n > 0 && OnRenew != nil { - err := OnRenew() + if n > 0 && OnChange != nil { + err := OnChange() if err != nil { - log.Printf("[ERROR] onrenew callback: %v\n", err) + log.Printf("[ERROR] onchange after cert renewal: %v\n", err) } } } case <-ocspTicker.C: - // TODO: Update OCSP + for bundle, oldStatus := range ocspStatus { + _, newStatus, err := acme.GetOCSPForCert(*bundle) + if err == nil && newStatus != oldStatus && OnChange != nil { + log.Printf("[INFO] ocsp status changed from %v to %v\n", oldStatus, newStatus) + err := OnChange() + if err != nil { + log.Printf("[ERROR] onchange after ocsp update: %v\n", err) + } + break + } + } case <-stopChan: renewalTicker.Stop() ocspTicker.Stop() From 976f5182e1694f43c3ee4cca7b503402b1049ea6 Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Thu, 29 Oct 2015 00:22:56 -0600 Subject: [PATCH 54/73] caddyfile: Better string and number handling --- caddy/caddyfile/json.go | 12 ++++++++---- caddy/caddyfile/json_test.go | 37 ++++++++++++++++++++++++++++++------ 2 files changed, 39 insertions(+), 10 deletions(-) diff --git a/caddy/caddyfile/json.go b/caddy/caddyfile/json.go index 20b36bcc..4617ec03 100644 --- a/caddy/caddyfile/json.go +++ b/caddy/caddyfile/json.go @@ -25,7 +25,7 @@ func ToJSON(caddyfile []byte) ([]byte, error) { block := ServerBlock{Body: make(map[string]interface{})} for _, host := range sb.HostList() { - block.Hosts = append(block.Hosts, host) + block.Hosts = append(block.Hosts, strings.TrimSuffix(host, ":")) } for dir, tokens := range sb.Tokens { @@ -107,7 +107,7 @@ func FromJSON(jsonBytes []byte) ([]byte, error) { if i > 0 { result += ", " } - result += host + result += strings.TrimSuffix(host, ":") } result += jsonToText(sb.Body, 1) } @@ -122,11 +122,15 @@ func jsonToText(scope interface{}, depth int) string { switch val := scope.(type) { case string: - result += " " + val + if strings.ContainsAny(val, "\" \n\t\r") { + result += ` "` + strings.Replace(val, "\"", "\\\"", -1) + `"` + } else { + result += " " + val + } case int: result += " " + strconv.Itoa(val) case float64: - result += " " + fmt.Sprintf("%f", val) + result += " " + fmt.Sprintf("%v", val) case bool: result += " " + fmt.Sprintf("%t", val) case map[string]interface{}: diff --git a/caddy/caddyfile/json_test.go b/caddy/caddyfile/json_test.go index 11e1b1f4..2e5ed445 100644 --- a/caddy/caddyfile/json_test.go +++ b/caddy/caddyfile/json_test.go @@ -6,26 +6,26 @@ var tests = []struct { caddyfile, json string }{ { // 0 - caddyfile: `foo: { + caddyfile: `foo { root /bar }`, - json: `[{"hosts":["foo:"],"body":{"root":["/bar"]}}]`, + json: `[{"hosts":["foo"],"body":{"root":["/bar"]}}]`, }, { // 1 - caddyfile: `host1:, host2: { + caddyfile: `host1, host2 { dir { def } }`, - json: `[{"hosts":["host1:","host2:"],"body":{"dir":[{"def":null}]}}]`, + json: `[{"hosts":["host1","host2"],"body":{"dir":[{"def":null}]}}]`, }, { // 2 - caddyfile: `host1:, host2: { + caddyfile: `host1, host2 { dir abc { def ghi } }`, - json: `[{"hosts":["host1:","host2:"],"body":{"dir":["abc",{"def":["ghi"]}]}}]`, + json: `[{"hosts":["host1","host2"],"body":{"dir":["abc",{"def":["ghi"]}]}}]`, }, { // 3 caddyfile: `host1:1234, host2:5678 { @@ -34,6 +34,31 @@ var tests = []struct { }`, json: `[{"hosts":["host1:1234","host2:5678"],"body":{"dir":["abc",{}]}}]`, }, + { // 4 + caddyfile: `host { + foo "bar baz" +}`, + json: `[{"hosts":["host"],"body":{"foo":["bar baz"]}}]`, + }, + { // 5 + caddyfile: `host, host:80 { + foo "bar \"baz\"" +}`, + json: `[{"hosts":["host","host:80"],"body":{"foo":["bar \"baz\""]}}]`, + }, + { // 6 + caddyfile: `host { + foo "bar +baz" +}`, + json: `[{"hosts":["host"],"body":{"foo":["bar\nbaz"]}}]`, + }, + { // 7 + caddyfile: `host { + dir 123 4.56 true +}`, + json: `[{"hosts":["host"],"body":{"dir":["123","4.56","true"]}}]`, // NOTE: I guess we assume numbers and booleans should be encoded as strings...? + }, } func TestToJSON(t *testing.T) { From efeeece73543c5ec7d94a5eb942583b366775c2a Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Thu, 29 Oct 2015 10:13:30 -0600 Subject: [PATCH 55/73] caddyfile: http and https hosts should render in URL format --- caddy/caddyfile/json.go | 6 ++++++ caddy/caddyfile/json_test.go | 5 +++++ 2 files changed, 11 insertions(+) diff --git a/caddy/caddyfile/json.go b/caddy/caddyfile/json.go index 4617ec03..42171e7a 100644 --- a/caddy/caddyfile/json.go +++ b/caddy/caddyfile/json.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "fmt" + "net" "strconv" "strings" @@ -104,6 +105,11 @@ func FromJSON(jsonBytes []byte) ([]byte, error) { for _, sb := range j { for i, host := range sb.Hosts { + if hostname, port, err := net.SplitHostPort(host); err == nil { + if port == "http" || port == "https" { + host = port + "://" + hostname + } + } if i > 0 { result += ", " } diff --git a/caddy/caddyfile/json_test.go b/caddy/caddyfile/json_test.go index 2e5ed445..44abf982 100644 --- a/caddy/caddyfile/json_test.go +++ b/caddy/caddyfile/json_test.go @@ -59,6 +59,11 @@ baz" }`, json: `[{"hosts":["host"],"body":{"dir":["123","4.56","true"]}}]`, // NOTE: I guess we assume numbers and booleans should be encoded as strings...? }, + { // 8 + caddyfile: `http://host, https://host { +}`, + json: `[{"hosts":["host:http","host:https"],"body":{}}]`, // hosts in JSON are always host:port format (if port is specified) + }, } func TestToJSON(t *testing.T) { From e3be524447a136241301723b1057bd1c73be5788 Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Thu, 29 Oct 2015 17:23:20 -0600 Subject: [PATCH 56/73] core: Fix for graceful reload after first reload signal The file path of the originally-loaded Caddyfile must be piped to the forked process; previously it was using stdin after the first fork, which wouldn't load the newest Caddyfile from disk, which is the point of SIGUSR1. --- caddy/caddy.go | 5 +---- caddy/helpers.go | 2 +- caddy/restart.go | 13 +++++++++++-- caddy/sigtrap_posix.go | 2 +- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/caddy/caddy.go b/caddy/caddy.go index 022cbb90..578b894b 100644 --- a/caddy/caddy.go +++ b/caddy/caddy.go @@ -264,10 +264,7 @@ func LoadCaddyfile(loader func() (Input, error)) (cdyfile Input, err error) { if err != nil { return nil, err } - cdyfile = CaddyfileInput{ - Filepath: os.Stdin.Name(), - Contents: loadedGob.Caddyfile, - } + cdyfile = loadedGob.Caddyfile } // Otherwise, we first try to get from stdin pipe diff --git a/caddy/helpers.go b/caddy/helpers.go index a22e7f5c..c606f941 100644 --- a/caddy/helpers.go +++ b/caddy/helpers.go @@ -43,7 +43,7 @@ func checkFdlimit() { // the caddyfile contents. Used only during graceful restarts. type caddyfileGob struct { ListenerFds map[string]uintptr - Caddyfile []byte + Caddyfile Input } // isRestart returns whether this process is, according diff --git a/caddy/restart.go b/caddy/restart.go index 1c61c5a0..7921f375 100644 --- a/caddy/restart.go +++ b/caddy/restart.go @@ -10,6 +10,10 @@ import ( "syscall" ) +func init() { + gob.Register(CaddyfileInput{}) +} + // Restart restarts the entire application; gracefully with zero // downtime if on a POSIX-compatible system, or forcefully if on // Windows but with imperceptibly-short downtime. @@ -17,6 +21,11 @@ import ( // The restarted application will use newCaddyfile as its input // configuration. If newCaddyfile is nil, the current (existing) // Caddyfile configuration will be used. +// +// Note: The process must exist in the same place on the disk in +// order for this to work. Thus, multiple graceful restarts don't +// work if executing with `go run`, since the binary is cleaned up +// when `go run` sees the initial parent process exit. func Restart(newCaddyfile Input) error { if newCaddyfile == nil { caddyfileMu.Lock() @@ -24,7 +33,7 @@ func Restart(newCaddyfile Input) error { caddyfileMu.Unlock() } - if len(os.Args) == 0 { // this should never happen, but just in case... + if len(os.Args) == 0 { // this should never happen... os.Args = []string{""} } @@ -34,7 +43,7 @@ func Restart(newCaddyfile Input) error { // Prepare our payload to the child process cdyfileGob := caddyfileGob{ ListenerFds: make(map[string]uintptr), - Caddyfile: newCaddyfile.Body(), + Caddyfile: newCaddyfile, } // Prepare a pipe to the fork's stdin so it can get the Caddyfile diff --git a/caddy/sigtrap_posix.go b/caddy/sigtrap_posix.go index 122adf2c..521866fd 100644 --- a/caddy/sigtrap_posix.go +++ b/caddy/sigtrap_posix.go @@ -36,7 +36,7 @@ func init() { err := Restart(updatedCaddyfile) if err != nil { - log.Println(err) + log.Println("error at restart:", err) } } }() From 64cded8246930bca28ade0dfb0ad6dbeb7194a60 Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Thu, 29 Oct 2015 17:24:11 -0600 Subject: [PATCH 57/73] letsencrypt: Don't maintain assets of sites we don't maintain --- caddy/letsencrypt/letsencrypt.go | 61 +++++++++++++++++++++----------- 1 file changed, 41 insertions(+), 20 deletions(-) diff --git a/caddy/letsencrypt/letsencrypt.go b/caddy/letsencrypt/letsencrypt.go index 4d58bebb..df822584 100644 --- a/caddy/letsencrypt/letsencrypt.go +++ b/caddy/letsencrypt/letsencrypt.go @@ -20,8 +20,9 @@ import ( // Activate sets up TLS for each server config in configs // as needed. It only skips the config if the cert and key -// are already provided or if plaintext http is explicitly -// specified as the port. +// are already provided, if plaintext http is explicitly +// specified as the port, TLS is explicitly disabled, or +// the host looks like a loopback or wildcard address. // // This function may prompt the user to provide an email // address if none is available through other means. It @@ -33,10 +34,14 @@ import ( // // Also note that calling this function activates asset // management automatically, which . +// +// Activate returns the updated list of configs, since +// some may have been appended, for example, to redirect +// plaintext HTTP requests to their HTTPS counterpart. func Activate(configs []server.Config) ([]server.Config, error) { // TODO: Is multiple activation (before a deactivation) an error? - // First identify and configure any eligible hosts for which + // Identify and configure any eligible hosts for which // we already have certs and keys in storage from last time. configLen := len(configs) // avoid infinite loop since this loop appends plaintext to the slice for i := 0; i < configLen; i++ { @@ -45,19 +50,22 @@ func Activate(configs []server.Config) ([]server.Config, error) { } } - // First renew any existing certificates that need it - renewCertificates(configs) + // Filter the configs by what we can maintain automatically + filteredConfigs := filterConfigs(configs) + + // Renew any existing certificates that need renewal + renewCertificates(filteredConfigs) // Group configs by LE email address; this will help us // reduce round-trips when getting the certs. - initMap, err := groupConfigsByEmail(configs) + groupedConfigs, err := groupConfigsByEmail(filteredConfigs) if err != nil { return configs, err } - // Loop through each email address and obtain certs; we can obtain more + // Loop through each email address and obtain certs; this way, we can obtain more // than one certificate per email address, and still save them individually. - for leEmail, serverConfigs := range initMap { + for leEmail, serverConfigs := range groupedConfigs { // make client to service this email address with CA server client, err := newClient(leEmail) if err != nil { @@ -82,8 +90,9 @@ func Activate(configs []server.Config) ([]server.Config, error) { } } + Deactivate() // in case previous caller wasn't clean about it stopChan = make(chan struct{}) - go maintainAssets(configs, stopChan) + go maintainAssets(filteredConfigs, stopChan) return configs, nil } @@ -102,14 +111,13 @@ func Deactivate() (err error) { return } -// groupConfigsByEmail groups configs by the Let's Encrypt email address -// associated to them or to the default Let's Encrypt email address. If the -// default email is not available, the user will be prompted to provide one. -// -// This function also filters out configs that don't need extra TLS help. -// Configurations with a manual TLS configuration or one that is already -// found in storage will not be added to any group. -func groupConfigsByEmail(configs []server.Config) (map[string][]*server.Config, error) { +// filterConfigs filters and returns configs that are eligible for automatic +// TLS by skipping configs that do not qualify for automatic maintenance +// of assets. Configurations with a manual TLS configuration or that already +// have an HTTPS counterpart host defined will be skipped. +func filterConfigs(configs []server.Config) []server.Config { + var filtered []server.Config + // configQualifies returns true if cfg qualifes for automatic LE activation configQualifies := func(cfg server.Config) bool { return cfg.TLS.Certificate == "" && // user could provide their own cert and key @@ -131,11 +139,22 @@ func groupConfigsByEmail(configs []server.Config) (map[string][]*server.Config, !hostHasOtherScheme(cfg.Host, "https", configs) } + for _, cfg := range configs { + if configQualifies(cfg) { + filtered = append(filtered, cfg) + } + } + + return filtered +} + +// groupConfigsByEmail groups configs by user email address. The returned map is +// a map of email address to the configs that are serviced under that account. +// If an email address is not available, the user will be prompted to provide one. +// This function assumes that all configs passed in qualify for automatic management. +func groupConfigsByEmail(configs []server.Config) (map[string][]*server.Config, error) { initMap := make(map[string][]*server.Config) for i := 0; i < len(configs); i++ { - if !configQualifies(configs[i]) { - continue - } leEmail := getEmail(configs[i]) if leEmail == "" { return nil, errors.New("must have email address to serve HTTPS without existing certificate and key") @@ -260,6 +279,8 @@ func saveCertsAndKeys(certificates []acme.CertificateResource) error { // autoConfigure enables TLS on cfg and appends, if necessary, a new config // to allConfigs that redirects plaintext HTTP to its new HTTPS counterpart. +// It expects the certificate and key to already be in storage. It returns +// the new list of allConfigs. func autoConfigure(cfg *server.Config, allConfigs []server.Config) []server.Config { bundleBytes, err := ioutil.ReadFile(storage.SiteCertFile(cfg.Host)) // TODO: Handle these errors better From 88c646c86c911999592af5c242b4c91f8c9b5001 Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Fri, 30 Oct 2015 00:19:43 -0600 Subject: [PATCH 58/73] core: Start() blocks until servers finish starting Also improved/clarified some docs --- caddy/caddy.go | 35 ++++++++++++++++++++++++++++------- caddy/caddy_test.go | 32 ++++++++++++++++++++++++++++++++ caddy/restart.go | 2 +- server/server.go | 30 +++++++++++++++++++++++++----- 4 files changed, 86 insertions(+), 13 deletions(-) create mode 100644 caddy/caddy_test.go diff --git a/caddy/caddy.go b/caddy/caddy.go index 578b894b..aa492b06 100644 --- a/caddy/caddy.go +++ b/caddy/caddy.go @@ -11,6 +11,10 @@ // // You should use caddy.Wait() to wait for all Caddy servers // to quit before your process exits. +// +// Importing this package has the side-effect of trapping +// SIGINT on all platforms and SIGUSR1 on not-Windows systems. +// It has to do this in order to perform shutdowns or reloads. package caddy import ( @@ -83,11 +87,13 @@ const ( // Start starts Caddy with the given Caddyfile. If cdyfile // is nil or the process is forked from a parent as part of // a graceful restart, Caddy will check to see if Caddyfile -// was piped from stdin and use that. +// was piped from stdin and use that. It blocks until all the +// servers are listening. // // If this process is a fork and no Caddyfile was piped in, -// an error will be returned. If this process is NOT a fork -// and cdyfile is nil, a default configuration will be assumed. +// an error will be returned (the Restart() function does this +// for you automatically). If this process is NOT a fork and +// cdyfile is nil, a default configuration will be assumed. // In any case, an error is returned if Caddy could not be // started. func Start(cdyfile Input) error { @@ -175,9 +181,12 @@ func Start(cdyfile Input) error { // startServers starts all the servers in groupings, // taking into account whether or not this process is -// a child from a graceful restart or not. +// a child from a graceful restart or not. It blocks +// until the servers are listening. func startServers(groupings Group) error { - for i, group := range groupings { + var startupWg sync.WaitGroup + + for _, group := range groupings { s, err := server.New(group.BindAddr.String(), group.Configs) if err != nil { log.Fatal(err) @@ -206,8 +215,9 @@ func startServers(groupings Group) error { } wg.Add(1) - go func(s *server.Server, i int, ln server.ListenerFile) { + go func(s *server.Server, ln server.ListenerFile) { defer wg.Done() + if ln != nil { err = s.Serve(ln) } else { @@ -222,16 +232,27 @@ func startServers(groupings Group) error { log.Println(err) } } - }(s, i, ln) + }(s, ln) + + startupWg.Add(1) + go func(s *server.Server) { + defer startupWg.Done() + s.WaitUntilStarted() + }(s) serversMu.Lock() servers = append(servers, s) serversMu.Unlock() } + + startupWg.Wait() + return nil } // Stop stops all servers. It blocks until they are all stopped. +// It does NOT execute shutdown callbacks that may have been +// configured by middleware (they are executed on SIGINT). func Stop() error { letsencrypt.Deactivate() diff --git a/caddy/caddy_test.go b/caddy/caddy_test.go new file mode 100644 index 00000000..ae84b31d --- /dev/null +++ b/caddy/caddy_test.go @@ -0,0 +1,32 @@ +package caddy + +import ( + "net/http" + "testing" + "time" +) + +func TestCaddyStartStop(t *testing.T) { + caddyfile := "localhost:1984\ntls off" + + for i := 0; i < 2; i++ { + err := Start(CaddyfileInput{Contents: []byte(caddyfile)}) + if err != nil { + t.Fatalf("Error starting, iteration %d: %v", i, err) + } + + client := http.Client{ + Timeout: time.Duration(2 * time.Second), + } + resp, err := client.Get("http://localhost:1984") + if err != nil { + t.Fatalf("Expected GET request to succeed (iteration %d), but it failed: %v", i, err) + } + resp.Body.Close() + + err = Stop() + if err != nil { + t.Fatalf("Error stopping, iteration %d: %v", i, err) + } + } +} diff --git a/caddy/restart.go b/caddy/restart.go index 7921f375..eae8604f 100644 --- a/caddy/restart.go +++ b/caddy/restart.go @@ -93,7 +93,7 @@ func Restart(newCaddyfile Input) error { sigwpipe.Close() // close our copy of the write end of the pipe or we might be stuck answer, err := ioutil.ReadAll(sigrpipe) if err != nil || len(answer) == 0 { - log.Println("restart: child failed to answer; changes not applied") + log.Println("restart: child failed to initialize; changes not applied") return incompleteRestartErr } diff --git a/server/server.go b/server/server.go index 9e7bcb38..a1ab6c58 100644 --- a/server/server.go +++ b/server/server.go @@ -32,6 +32,7 @@ type Server struct { listener ListenerFile // the listener which is bound to the socket listenerMu sync.Mutex // protects listener httpWg sync.WaitGroup // used to wait on outstanding connections + startChan chan struct{} // used to block until server is finished starting } type ListenerFile interface { @@ -42,6 +43,11 @@ type ListenerFile interface { // New creates a new Server which will bind to addr and serve // the sites/hosts configured in configs. This function does // not start serving. +// +// Do not re-use a server (start, stop, then start again). We +// could probably add more locking to make this possible, but +// as it stands, you should dispose of a server after stopping it. +// The behavior of serving with a spent server is undefined. func New(addr string, configs []Config) (*Server, error) { var tls bool if len(configs) > 0 { @@ -56,8 +62,9 @@ func New(addr string, configs []Config) (*Server, error) { // WriteTimeout: 2 * time.Minute, // MaxHeaderBytes: 1 << 16, }, - tls: tls, - vhosts: make(map[string]virtualHost), + tls: tls, + vhosts: make(map[string]virtualHost), + startChan: make(chan struct{}), } s.Handler = s // this is weird, but whatever @@ -94,6 +101,7 @@ func New(addr string, configs []Config) (*Server, error) { func (s *Server) Serve(ln ListenerFile) error { err := s.setup() if err != nil { + close(s.startChan) return err } return s.serve(ln) @@ -103,11 +111,13 @@ func (s *Server) Serve(ln ListenerFile) error { func (s *Server) ListenAndServe() error { err := s.setup() if err != nil { + close(s.startChan) return err } ln, err := net.Listen("tcp", s.Addr) if err != nil { + close(s.startChan) return err } @@ -136,6 +146,7 @@ func (s *Server) serve(ln ListenerFile) error { return serveTLSWithSNI(s, s.listener, tlsConfigs) } + close(s.startChan) // unblock anyone waiting for this to start listening return s.Server.Serve(s.listener) } @@ -182,6 +193,7 @@ func serveTLSWithSNI(s *Server, ln net.Listener, tlsConfigs []TLSConfig) error { config.Certificates[i], err = tls.LoadX509KeyPair(tlsConfig.Certificate, tlsConfig.Key) config.Certificates[i].OCSPStaple = tlsConfig.OCSPStaple if err != nil { + close(s.startChan) return err } } @@ -196,6 +208,7 @@ func serveTLSWithSNI(s *Server, ln net.Listener, tlsConfigs []TLSConfig) error { // TLS client authentication, if user enabled it err = setupClientAuth(tlsConfigs, config) if err != nil { + close(s.startChan) return err } @@ -205,7 +218,7 @@ func serveTLSWithSNI(s *Server, ln net.Listener, tlsConfigs []TLSConfig) error { // on POSIX systems. ln = tls.NewListener(ln, config) - // Begin serving; block until done + close(s.startChan) // unblock anyone waiting for this to start listening return s.Server.Serve(ln) } @@ -215,7 +228,7 @@ func serveTLSWithSNI(s *Server, ln net.Listener, tlsConfigs []TLSConfig) error { // seconds); on Windows it will close the listener // immediately. func (s *Server) Stop() error { - s.Server.SetKeepAlivesEnabled(false) // TODO: Does this even do anything? :P + s.Server.SetKeepAlivesEnabled(false) if runtime.GOOS != "windows" { // force connections to close after timeout @@ -229,7 +242,7 @@ func (s *Server) Stop() error { // Wait for remaining connections to finish or // force them all to close after timeout select { - case <-time.After(5 * time.Second): // TODO: make configurable? + case <-time.After(5 * time.Second): // TODO: make configurable case <-done: } } @@ -246,6 +259,13 @@ func (s *Server) Stop() error { return err } +// WaitUntilStarted blocks until the server s is started, meaning +// that practically the next instruction is to start the server loop. +// It also unblocks if the server encounters an error during startup. +func (s *Server) WaitUntilStarted() { + <-s.startChan +} + // ListenerFd gets the file descriptor of the listener. func (s *Server) ListenerFd() uintptr { s.listenerMu.Lock() From e99b3af0a5c213ac666cb842e8286ea49db17eb5 Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Fri, 30 Oct 2015 15:55:59 -0600 Subject: [PATCH 59/73] letsencrypt: Numerous bug fixes --- caddy/caddy.go | 2 +- caddy/caddyfile/json_test.go | 2 +- caddy/config.go | 9 +- caddy/letsencrypt/letsencrypt.go | 147 ++++++++++++++++++------------- caddy/letsencrypt/maintain.go | 8 +- main.go | 2 +- 6 files changed, 96 insertions(+), 74 deletions(-) diff --git a/caddy/caddy.go b/caddy/caddy.go index aa492b06..830f046d 100644 --- a/caddy/caddy.go +++ b/caddy/caddy.go @@ -276,7 +276,7 @@ func Wait() { // the Caddyfile. If loader does not return a Caddyfile, the // default one will be returned. Thus, if there are no other // errors, this function always returns at least the default -// Caddyfile. +// Caddyfile (not the previously-used Caddyfile). func LoadCaddyfile(loader func() (Input, error)) (cdyfile Input, err error) { // If we are a fork, finishing the restart is highest priority; // piped input is required in this case. diff --git a/caddy/caddyfile/json_test.go b/caddy/caddyfile/json_test.go index 44abf982..f0848b1b 100644 --- a/caddy/caddyfile/json_test.go +++ b/caddy/caddyfile/json_test.go @@ -62,7 +62,7 @@ baz" { // 8 caddyfile: `http://host, https://host { }`, - json: `[{"hosts":["host:http","host:https"],"body":{}}]`, // hosts in JSON are always host:port format (if port is specified) + json: `[{"hosts":["host:http","host:https"],"body":{}}]`, // hosts in JSON are always host:port format (if port is specified), for consistency }, } diff --git a/caddy/config.go b/caddy/config.go index 53432b4e..503faecc 100644 --- a/caddy/config.go +++ b/caddy/config.go @@ -89,10 +89,6 @@ func load(filename string, input io.Reader) ([]server.Config, error) { } } - if config.Port == "" { - config.Port = Port - } - configs = append(configs, config) } } @@ -145,6 +141,11 @@ func arrangeBindings(allConfigs []server.Config) (Group, error) { // Group configs by bind address for _, conf := range allConfigs { + // use default port if none is specified + if conf.Port == "" { + conf.Port = Port + } + bindAddr, warnErr, fatalErr := resolveAddr(conf) if fatalErr != nil { return groupings, fatalErr diff --git a/caddy/letsencrypt/letsencrypt.go b/caddy/letsencrypt/letsencrypt.go index df822584..d131fbb8 100644 --- a/caddy/letsencrypt/letsencrypt.go +++ b/caddy/letsencrypt/letsencrypt.go @@ -39,32 +39,35 @@ import ( // some may have been appended, for example, to redirect // plaintext HTTP requests to their HTTPS counterpart. func Activate(configs []server.Config) ([]server.Config, error) { + // just in case previous caller forgot... + Deactivate() // TODO: Is multiple activation (before a deactivation) an error? + // reset cached ocsp statuses from any previous activations + ocspStatus = make(map[*[]byte]int) + // Identify and configure any eligible hosts for which // we already have certs and keys in storage from last time. configLen := len(configs) // avoid infinite loop since this loop appends plaintext to the slice for i := 0; i < configLen; i++ { - if existingCertAndKey(configs[i].Host) && configs[i].TLS.LetsEncryptEmail != "off" { + if existingCertAndKey(configs[i].Host) && configQualifies(configs[i], configs) { configs = autoConfigure(&configs[i], configs) } } - // Filter the configs by what we can maintain automatically - filteredConfigs := filterConfigs(configs) - - // Renew any existing certificates that need renewal - renewCertificates(filteredConfigs) - - // Group configs by LE email address; this will help us - // reduce round-trips when getting the certs. - groupedConfigs, err := groupConfigsByEmail(filteredConfigs) + // Group configs by email address; only configs that are eligible + // for TLS management are included. We group by email so that we + // can request certificates in batches with the same client. + // Note: The return value is a map, and iteration over a map is + // not ordered. I don't think it will be a problem, but if an + // ordering problem arises, look at this carefully. + groupedConfigs, err := groupConfigsByEmail(configs) if err != nil { return configs, err } - // Loop through each email address and obtain certs; this way, we can obtain more - // than one certificate per email address, and still save them individually. + // obtain certificates for configs that need one, and reconfigure each + // config to use the certificates for leEmail, serverConfigs := range groupedConfigs { // make client to service this email address with CA server client, err := newClient(leEmail) @@ -75,7 +78,7 @@ func Activate(configs []server.Config) ([]server.Config, error) { // client is ready, so let's get free, trusted SSL certificates! yeah! certificates, err := obtainCertificates(client, serverConfigs) if err != nil { - return configs, errors.New("error obtaining cert: " + err.Error()) + return configs, errors.New("error getting certs: " + err.Error()) } // ... that's it. save the certs, keys, and metadata files to disk @@ -84,15 +87,17 @@ func Activate(configs []server.Config) ([]server.Config, error) { return configs, errors.New("error saving assets: " + err.Error()) } - // it all comes down to this: turning TLS on for all the configs - for _, cfg := range serverConfigs { - configs = autoConfigure(cfg, configs) + // it all comes down to this: turning on TLS with all the new certs + for i := 0; i < len(serverConfigs); i++ { + configs = autoConfigure(serverConfigs[i], configs) } } - Deactivate() // in case previous caller wasn't clean about it - stopChan = make(chan struct{}) - go maintainAssets(filteredConfigs, stopChan) + // renew all certificates that need renewal + renewCertificates(configs) + + // keep certificates renewed and OCSP stapling updated + go maintainAssets(configs, stopChan) return configs, nil } @@ -108,55 +113,51 @@ func Deactivate() (err error) { } }() close(stopChan) + stopChan = make(chan struct{}) return } -// filterConfigs filters and returns configs that are eligible for automatic -// TLS by skipping configs that do not qualify for automatic maintenance -// of assets. Configurations with a manual TLS configuration or that already -// have an HTTPS counterpart host defined will be skipped. -func filterConfigs(configs []server.Config) []server.Config { - var filtered []server.Config +// configQualifies returns true if cfg qualifes for automatic LE activation, +// but it does require the list of all configs to be passed in as well. +// It does NOT check to see if a cert and key already exist for cfg. +func configQualifies(cfg server.Config, allConfigs []server.Config) bool { + return cfg.TLS.Certificate == "" && // user could provide their own cert and key + cfg.TLS.Key == "" && - // configQualifies returns true if cfg qualifes for automatic LE activation - configQualifies := func(cfg server.Config) bool { - return cfg.TLS.Certificate == "" && // user could provide their own cert and key - cfg.TLS.Key == "" && + // user can force-disable automatic HTTPS for this host + cfg.Port != "http" && + cfg.TLS.LetsEncryptEmail != "off" && - // user can force-disable automatic HTTPS for this host - cfg.Port != "http" && - cfg.TLS.LetsEncryptEmail != "off" && + // obviously we get can't certs for loopback or internal hosts + cfg.Host != "localhost" && + cfg.Host != "" && + cfg.Host != "0.0.0.0" && + cfg.Host != "::1" && + !strings.HasPrefix(cfg.Host, "127.") && + // TODO: Also exclude 10.* and 192.168.* addresses? - // obviously we get can't certs for loopback or internal hosts - cfg.Host != "localhost" && - cfg.Host != "" && - cfg.Host != "0.0.0.0" && - cfg.Host != "::1" && - !strings.HasPrefix(cfg.Host, "127.") && - !strings.HasPrefix(cfg.Host, "10.") && - - // make sure an HTTPS version of this config doesn't exist in the list already - !hostHasOtherScheme(cfg.Host, "https", configs) - } - - for _, cfg := range configs { - if configQualifies(cfg) { - filtered = append(filtered, cfg) - } - } - - return filtered + // make sure an HTTPS version of this config doesn't exist in the list already + !hostHasOtherScheme(cfg.Host, "https", allConfigs) } // groupConfigsByEmail groups configs by user email address. The returned map is // a map of email address to the configs that are serviced under that account. -// If an email address is not available, the user will be prompted to provide one. -// This function assumes that all configs passed in qualify for automatic management. +// If an email address is not available for an eligible config, the user will be +// prompted to provide one. The returned map contains pointers to the original +// server config values. func groupConfigsByEmail(configs []server.Config) (map[string][]*server.Config, error) { initMap := make(map[string][]*server.Config) for i := 0; i < len(configs); i++ { + // filter out configs that we already have certs for and + // that we won't be obtaining certs for - this way we won't + // bother the user for an email address unnecessarily and + // we don't obtain new certs for a host we already have certs for. + if existingCertAndKey(configs[i].Host) || !configQualifies(configs[i], configs) { + continue + } leEmail := getEmail(configs[i]) if leEmail == "" { + // TODO: This may not be an error; just a poor choice by the user return nil, errors.New("must have email address to serve HTTPS without existing certificate and key") } initMap[leEmail] = append(initMap[leEmail], &configs[i]) @@ -280,7 +281,8 @@ func saveCertsAndKeys(certificates []acme.CertificateResource) error { // autoConfigure enables TLS on cfg and appends, if necessary, a new config // to allConfigs that redirects plaintext HTTP to its new HTTPS counterpart. // It expects the certificate and key to already be in storage. It returns -// the new list of allConfigs. +// the new list of allConfigs, since it may append a new config. This function +// assumes that cfg was already set up for HTTPS. func autoConfigure(cfg *server.Config, allConfigs []server.Config) []server.Config { bundleBytes, err := ioutil.ReadFile(storage.SiteCertFile(cfg.Host)) // TODO: Handle these errors better @@ -294,7 +296,9 @@ func autoConfigure(cfg *server.Config, allConfigs []server.Config) []server.Conf cfg.TLS.Certificate = storage.SiteCertFile(cfg.Host) cfg.TLS.Key = storage.SiteKeyFile(cfg.Host) cfg.TLS.Enabled = true - cfg.Port = "https" + if cfg.Port == "" { + cfg.Port = "https" + } // Set up http->https redirect as long as there isn't already // a http counterpart in the configs @@ -308,11 +312,21 @@ func autoConfigure(cfg *server.Config, allConfigs []server.Config) []server.Conf // hostHasOtherScheme tells you whether there is another config in the list // for the same host but with the port equal to scheme. For example, to see // if example.com has a https variant already, pass in example.com and -// "https" along with the list of configs. +// "https" along with the list of configs. This function considers "443" +// and "https" to be the same scheme, as well as "http" and "80". func hostHasOtherScheme(host, scheme string, allConfigs []server.Config) bool { + if scheme == "80" { + scheme = "http" + } else if scheme == "443" { + scheme = "https" + } for _, otherCfg := range allConfigs { - if otherCfg.Host == host && otherCfg.Port == scheme { - return true + if otherCfg.Host == host { + if (otherCfg.Port == scheme) || + (scheme == "https" && otherCfg.Port == "443") || + (scheme == "http" && otherCfg.Port == "80") { + return true + } } } return false @@ -323,12 +337,17 @@ func hostHasOtherScheme(host, scheme string, allConfigs []server.Config) bool { // be the HTTPS configuration. The returned configuration is set // to listen on the "http" port (port 80). func redirPlaintextHost(cfg server.Config) server.Config { + toUrl := "https://" + cfg.Host + if cfg.Port != "https" && cfg.Port != "http" { + toUrl += ":" + cfg.Port + } + redirMidware := func(next middleware.Handler) middleware.Handler { return redirect.Redirect{Next: next, Rules: []redirect.Rule{ { FromScheme: "http", FromPath: "/", - To: "https://" + cfg.Host + "{uri}", + To: toUrl + "{uri}", Code: http.StatusMovedPermanently, }, }} @@ -391,13 +410,15 @@ var ( // Some essential values related to the Let's Encrypt process const ( - // The port to expose to the CA server for Simple HTTP Challenge - exposePort = "5001" + // The port to expose to the CA server for Simple HTTP Challenge. + // NOTE: Let's Encrypt requires port 443. If exposePort is not 443, + // then port 443 must be forwarded to exposePort. + exposePort = "443" - // How often to check certificates for renewal + // How often to check certificates for renewal. renewInterval = 24 * time.Hour - // How often to update OCSP stapling + // How often to update OCSP stapling. ocspInterval = 1 * time.Hour ) diff --git a/caddy/letsencrypt/maintain.go b/caddy/letsencrypt/maintain.go index 62af5e1c..ae364749 100644 --- a/caddy/letsencrypt/maintain.go +++ b/caddy/letsencrypt/maintain.go @@ -25,7 +25,7 @@ var OnChange func() error // // You must pass in the server configs to maintain and the channel // which you'll close when maintenance should stop, to allow this -// goroutine to clean up after itself. +// goroutine to clean up after itself and unblock. func maintainAssets(configs []server.Config, stopChan chan struct{}) { renewalTicker := time.NewTicker(renewInterval) ocspTicker := time.NewTicker(ocspInterval) @@ -66,7 +66,7 @@ func maintainAssets(configs []server.Config, stopChan chan struct{}) { // renewCertificates loops through all configured site and // looks for certificates to renew. Nothing is mutated -// through this function. The changes happen directly on disk. +// through this function; all changes happen directly on disk. // It returns the number of certificates renewed and any errors // that occurred. It only performs a renewal if necessary. func renewCertificates(configs []server.Config) (int, []error) { @@ -75,7 +75,7 @@ func renewCertificates(configs []server.Config) (int, []error) { var n int for _, cfg := range configs { - // Host must be TLS-enabled and have assets managed by LE + // Host must be TLS-enabled and have existing assets managed by LE if !cfg.TLS.Enabled || !existingCertAndKey(cfg.Host) { continue } @@ -100,7 +100,7 @@ func renewCertificates(configs []server.Config) (int, []error) { // Renew with a week or less remaining. if daysLeft <= 7 { log.Printf("[INFO] There are %d days left on the certificate of %s. Trying to renew now.", daysLeft, cfg.Host) - client, err := newClient(getEmail(cfg)) + client, err := newClient("") // email not used for renewal if err != nil { errs = append(errs, err) continue diff --git a/main.go b/main.go index aa3ed0d8..3c493003 100644 --- a/main.go +++ b/main.go @@ -41,7 +41,7 @@ func init() { // TODO: Production endpoint is: https://acme-v01.api.letsencrypt.org flag.StringVar(&letsencrypt.CAUrl, "ca", "https://acme-staging.api.letsencrypt.org", "Certificate authority ACME server") flag.BoolVar(&letsencrypt.Agreed, "agree", false, "Agree to Let's Encrypt Subscriber Agreement") - flag.StringVar(&letsencrypt.DefaultEmail, "email", "", "Default email address to use for Let's Encrypt transactions") + flag.StringVar(&letsencrypt.DefaultEmail, "email", "", "Default Let's Encrypt account email address") flag.StringVar(&revoke, "revoke", "", "Hostname for which to revoke the certificate") } From 3843cea95910840a3974ff7442a392b534167640 Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Fri, 30 Oct 2015 23:44:00 -0600 Subject: [PATCH 60/73] letsencrypt: Allow (but warn about) empty emails --- caddy/letsencrypt/letsencrypt.go | 4 ---- caddy/letsencrypt/storage.go | 10 +++++++++- caddy/letsencrypt/storage_test.go | 15 +++++++++++++++ caddy/letsencrypt/user.go | 17 +++++++++++++---- 4 files changed, 37 insertions(+), 9 deletions(-) diff --git a/caddy/letsencrypt/letsencrypt.go b/caddy/letsencrypt/letsencrypt.go index d131fbb8..f7a5fd54 100644 --- a/caddy/letsencrypt/letsencrypt.go +++ b/caddy/letsencrypt/letsencrypt.go @@ -156,10 +156,6 @@ func groupConfigsByEmail(configs []server.Config) (map[string][]*server.Config, continue } leEmail := getEmail(configs[i]) - if leEmail == "" { - // TODO: This may not be an error; just a poor choice by the user - return nil, errors.New("must have email address to serve HTTPS without existing certificate and key") - } initMap[leEmail] = append(initMap[leEmail], &configs[i]) } return initMap, nil diff --git a/caddy/letsencrypt/storage.go b/caddy/letsencrypt/storage.go index 6826e930..81c0aaea 100644 --- a/caddy/letsencrypt/storage.go +++ b/caddy/letsencrypt/storage.go @@ -48,12 +48,18 @@ func (s Storage) Users() string { // User gets the account folder for the user with email. func (s Storage) User(email string) string { + if email == "" { + email = emptyEmail + } return filepath.Join(s.Users(), email) } // UserRegFile gets the path to the registration file for // the user with the given email address. func (s Storage) UserRegFile(email string) string { + if email == "" { + email = emptyEmail + } fileName := emailUsername(email) if fileName == "" { fileName = "registration" @@ -64,7 +70,9 @@ func (s Storage) UserRegFile(email string) string { // UserKeyFile gets the path to the private key file for // the user with the given email address. func (s Storage) UserKeyFile(email string) string { - // TODO: Read the KeyFile property in the registration file instead? + if email == "" { + email = emptyEmail + } fileName := emailUsername(email) if fileName == "" { fileName = "private" diff --git a/caddy/letsencrypt/storage_test.go b/caddy/letsencrypt/storage_test.go index 67368669..5107c32a 100644 --- a/caddy/letsencrypt/storage_test.go +++ b/caddy/letsencrypt/storage_test.go @@ -35,6 +35,17 @@ func TestStorage(t *testing.T) { if expected, actual := filepath.Join("letsencrypt", "users", "me@example.com", "me.key"), storage.UserKeyFile("me@example.com"); actual != expected { t.Errorf("Expected UserKeyFile() to return '%s' but got '%s'", expected, actual) } + + // Test with empty emails + if expected, actual := filepath.Join("letsencrypt", "users", emptyEmail), storage.User(emptyEmail); actual != expected { + t.Errorf("Expected User(\"\") to return '%s' but got '%s'", expected, actual) + } + if expected, actual := filepath.Join("letsencrypt", "users", emptyEmail, emptyEmail+".json"), storage.UserRegFile(""); actual != expected { + t.Errorf("Expected UserRegFile(\"\") to return '%s' but got '%s'", expected, actual) + } + if expected, actual := filepath.Join("letsencrypt", "users", emptyEmail, emptyEmail+".key"), storage.UserKeyFile(""); actual != expected { + t.Errorf("Expected UserKeyFile(\"\") to return '%s' but got '%s'", expected, actual) + } } func TestEmailUsername(t *testing.T) { @@ -61,6 +72,10 @@ func TestEmailUsername(t *testing.T) { input: "@foobar.com", expect: "foobar.com", }, + { + input: emptyEmail, + expect: emptyEmail, + }, } { if actual := emailUsername(test.input); actual != test.expect { t.Errorf("Test %d: Expected username to be '%s' but was '%s'", i, test.expect, actual) diff --git a/caddy/letsencrypt/user.go b/caddy/letsencrypt/user.go index ff4d6acb..0e7a8b45 100644 --- a/caddy/letsencrypt/user.go +++ b/caddy/letsencrypt/user.go @@ -115,6 +115,8 @@ func newUser(email string) (User, error) { // getEmail does everything it can to obtain an email // address from the user to use for TLS for cfg. If it // cannot get an email address, it returns empty string. +// (It will warn the user of the consequences of an +// empty email.) func getEmail(cfg server.Config) string { // First try the tls directive from the Caddyfile leEmail := cfg.TLS.LetsEncryptEmail @@ -124,7 +126,6 @@ func getEmail(cfg server.Config) string { } if leEmail == "" { // Then try to get most recent user email ~/.caddy/users file - // TODO: Probably better to open the user's json file and read the email out of there... userDirs, err := ioutil.ReadDir(storage.Users()) if err == nil { var mostRecent os.FileInfo @@ -143,9 +144,13 @@ func getEmail(cfg server.Config) string { } if leEmail == "" { // Alas, we must bother the user and ask for an email address - // TODO/BUG: This doesn't work when Caddyfile is piped into caddy reader := bufio.NewReader(stdin) - fmt.Print("Email address: ") // TODO: More explanation probably, and show ToS? + fmt.Println("Your 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(" ") + fmt.Println("Please enter your email address so you can recover your account if needed.") + fmt.Println("You can leave it blank, but you lose the ability to recover your account.") + fmt.Print("Email address: ") var err error leEmail, err = reader.ReadString('\n') if err != nil { @@ -169,7 +174,7 @@ func promptUserAgreement(agreementURL string, changed bool) bool { fmt.Print("Do you agree to the terms? (y/n): ") } - reader := bufio.NewReader(stdin) // TODO/BUG: This doesn't work when Caddyfile is piped into caddy + reader := bufio.NewReader(stdin) answer, err := reader.ReadString('\n') if err != nil { return false @@ -182,3 +187,7 @@ func promptUserAgreement(agreementURL string, changed bool) bool { // 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. +const emptyEmail = "default" From e4028b23c74ae37a8cf2bb83dae4e65b37469125 Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Sat, 31 Oct 2015 13:15:47 -0600 Subject: [PATCH 61/73] letsencrypt: Email prompt includes link to SA --- caddy/letsencrypt/letsencrypt.go | 7 ++++--- caddy/letsencrypt/user.go | 6 ++++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/caddy/letsencrypt/letsencrypt.go b/caddy/letsencrypt/letsencrypt.go index f7a5fd54..b060afa7 100644 --- a/caddy/letsencrypt/letsencrypt.go +++ b/caddy/letsencrypt/letsencrypt.go @@ -30,10 +30,12 @@ import ( // if that is not available it will check the command line // argument. If absent, it will use the most recent email // address from last time. If there isn't one, the user -// will be prompted. If the user leaves email blank, . +// will be prompted and shown SA link. // // Also note that calling this function activates asset -// management automatically, which . +// management automatically, which keeps certificates +// renewed and OCSP stapling updated. This has the effect +// of causing restarts when assets are updated. // // Activate returns the updated list of configs, since // some may have been appended, for example, to redirect @@ -41,7 +43,6 @@ import ( func Activate(configs []server.Config) ([]server.Config, error) { // just in case previous caller forgot... Deactivate() - // TODO: Is multiple activation (before a deactivation) an error? // reset cached ocsp statuses from any previous activations ocspStatus = make(map[*[]byte]int) diff --git a/caddy/letsencrypt/user.go b/caddy/letsencrypt/user.go index 0e7a8b45..1e1ab2c4 100644 --- a/caddy/letsencrypt/user.go +++ b/caddy/letsencrypt/user.go @@ -143,11 +143,12 @@ func getEmail(cfg server.Config) string { } } if leEmail == "" { - // Alas, we must bother the user and ask for an email address + // 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("Your 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(" ") + fmt.Println(" https://letsencrypt.org/documents/LE-SA-v1.0.1-July-27-2015.pdf") // 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 lose the ability to recover your account.") fmt.Print("Email address: ") @@ -157,6 +158,7 @@ func getEmail(cfg server.Config) string { return "" } DefaultEmail = leEmail + Agreed = true } return strings.TrimSpace(leEmail) } From 4d71620cb08816e17752a71bd59d544a9514cd29 Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Sat, 31 Oct 2015 13:22:23 -0600 Subject: [PATCH 62/73] core (Windows): Retry every 100ms for 2s if listener fails to bind In testing, I've found that Windows doesn't release the socket right away even though the listener is closed, so calling caddy.Start() right after caddy.Stop() can fail. This change has server.ListenAndServe() try up to 20 times every 100ms to bind the listener, and only return an error if it doesn't succeed after 2 seconds. This might be kind of nifty for Unix, too, but there hasn't been a need for it yet. --- server/server.go | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/server/server.go b/server/server.go index a1ab6c58..15128996 100644 --- a/server/server.go +++ b/server/server.go @@ -117,8 +117,21 @@ func (s *Server) ListenAndServe() error { ln, err := net.Listen("tcp", s.Addr) if err != nil { - close(s.startChan) - return err + var succeeded bool + if runtime.GOOS == "windows" { // TODO: Limit this to Windows only? (it keeps sockets open after closing listeners) + for i := 0; i < 20; i++ { + time.Sleep(100 * time.Millisecond) + ln, err = net.Listen("tcp", s.Addr) + if err == nil { + succeeded = true + break + } + } + } + if !succeeded { + close(s.startChan) + return err + } } return s.serve(ln.(*net.TCPListener)) From a729be295a9bf224632f33c29bd029a4ea46d69a Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Sun, 1 Nov 2015 09:46:23 -0700 Subject: [PATCH 63/73] letsencrypt: Activate during config load just after tls directive Before, we were activating Let's Encrypt after all the directives were executed. This means their setup functions had access to potentially erroneous information about the server's TLS setup, since the letsencrypt package makes changes to the port, etc. Now, we execute all directives up to and including tls, then activate letsencrypt, then finish with the rest of the directives. It's a bit ugly, but I do think it is more correct. It also fixes some bugs, for example: a host that only has a catch-all redirect. --- caddy/caddy.go | 10 +--- caddy/config.go | 90 ++++++++++++++++++++++++++++---- caddy/directives.go | 2 +- caddy/letsencrypt/letsencrypt.go | 6 +++ 4 files changed, 89 insertions(+), 19 deletions(-) diff --git a/caddy/caddy.go b/caddy/caddy.go index 830f046d..5d3785e1 100644 --- a/caddy/caddy.go +++ b/caddy/caddy.go @@ -113,14 +113,8 @@ func Start(cdyfile Input) error { caddyfile = cdyfile caddyfileMu.Unlock() - // load the server configs - configs, err := load(path.Base(cdyfile.Path()), bytes.NewReader(cdyfile.Body())) - if err != nil { - return err - } - - // secure all the things - configs, err = letsencrypt.Activate(configs) + // load the server configs (activates Let's Encrypt) + configs, err := loadConfigs(path.Base(cdyfile.Path()), bytes.NewReader(cdyfile.Body())) if err != nil { return err } diff --git a/caddy/config.go b/caddy/config.go index 503faecc..4bf111a1 100644 --- a/caddy/config.go +++ b/caddy/config.go @@ -7,6 +7,7 @@ import ( "net" "sync" + "github.com/mholt/caddy/caddy/letsencrypt" "github.com/mholt/caddy/caddy/parse" "github.com/mholt/caddy/caddy/setup" "github.com/mholt/caddy/middleware" @@ -19,15 +20,20 @@ const ( DefaultConfigFile = "Caddyfile" ) -// load reads input (named filename) and parses it, returning the -// server configurations in the order they appeared in the input. -func load(filename string, input io.Reader) ([]server.Config, error) { +// loadConfigs reads input (named filename) and parses it, returning the +// server configurations in the order they appeared in the input. As part +// of this, it activates Let's Encrypt for the configs that are produced. +// Thus, the returned configs are already optimally configured optimally +// for HTTPS. +func loadConfigs(filename string, input io.Reader) ([]server.Config, error) { var configs []server.Config // turn off timestamp for parsing flags := log.Flags() log.SetFlags(0) + // Each server block represents similar hosts/addresses, since they + // were grouped together in the Caddyfile. serverBlocks, err := parse.ServerBlocks(filename, input, true) if err != nil { return nil, err @@ -36,9 +42,11 @@ func load(filename string, input io.Reader) ([]server.Config, error) { return []server.Config{NewDefault()}, nil } - // Each server block represents similar hosts/addresses. + var lastDirectiveIndex int // we set up directives in two parts; this stores where we left off + // Iterate each server block and make a config for each one, - // executing the directives that were parsed. + // executing the directives that were parsed in order up to the tls + // directive; this is because we must activate Let's Encrypt. for i, sb := range serverBlocks { onces := makeOnces() storages := makeStorages() @@ -55,12 +63,12 @@ func load(filename string, input io.Reader) ([]server.Config, error) { } // It is crucial that directives are executed in the proper order. - for _, dir := range directiveOrder { + for k, dir := range directiveOrder { // Execute directive if it is in the server block if tokens, ok := sb.Tokens[dir.name]; ok { - // Each setup function gets a controller, which is the - // server config and the dispenser containing only - // this directive's tokens. + // Each setup function gets a controller, from which setup functions + // get access to the config, tokens, and other state information useful + // to set up its own host only. controller := &setup.Controller{ Config: &config, Dispenser: parse.NewDispenserTokens(filename, tokens), @@ -76,7 +84,7 @@ func load(filename string, input io.Reader) ([]server.Config, error) { ServerBlockHosts: sb.HostList(), ServerBlockStorage: storages[dir.name], } - + // execute setup function and append middleware handler, if any midware, err := dir.setup(controller) if err != nil { return nil, err @@ -87,12 +95,74 @@ func load(filename string, input io.Reader) ([]server.Config, error) { } storages[dir.name] = controller.ServerBlockStorage // persist for this server block } + + // Stop after TLS setup, since we need to activate Let's Encrypt before continuing; + // it makes some changes to the configs that middlewares might want to know about. + if dir.name == "tls" { + lastDirectiveIndex = k + break + } } configs = append(configs, config) } } + // Now we have all the configs, but they have only been set up to the + // point of tls. We need to activate Let's Encrypt before setting up + // the rest of the middlewares so they have correct information regarding + // TLS configuration, if necessary. (this call is append-only, so our + // iterations below shouldn't be affected) + configs, err = letsencrypt.Activate(configs) + if err != nil { + return nil, err + } + + // Finish setting up the rest of the directives, now that TLS is + // optimally configured. These loops are similar to above except + // we don't iterate all the directives from the beginning and we + // don't create new configs. + configIndex := -1 + for i, sb := range serverBlocks { + onces := makeOnces() + storages := makeStorages() + + for j := range sb.Addresses { + configIndex++ + + for k := lastDirectiveIndex + 1; k < len(directiveOrder); k++ { + dir := directiveOrder[k] + + if tokens, ok := sb.Tokens[dir.name]; ok { + controller := &setup.Controller{ + Config: &configs[configIndex], + Dispenser: parse.NewDispenserTokens(filename, tokens), + OncePerServerBlock: func(f func() error) error { + var err error + onces[dir.name].Do(func() { + err = f() + }) + return err + }, + ServerBlockIndex: i, + ServerBlockHostIndex: j, + ServerBlockHosts: sb.HostList(), + ServerBlockStorage: storages[dir.name], + } + midware, err := dir.setup(controller) + if err != nil { + return nil, err + } + if midware != nil { + // TODO: For now, we only support the default path scope / + configs[configIndex].Middleware["/"] = append(configs[configIndex].Middleware["/"], midware) + } + storages[dir.name] = controller.ServerBlockStorage // persist for this server block + } + } + } + } + // restore logging settings log.SetFlags(flags) diff --git a/caddy/directives.go b/caddy/directives.go index 3ebee795..54298502 100644 --- a/caddy/directives.go +++ b/caddy/directives.go @@ -42,7 +42,7 @@ func init() { var directiveOrder = []directive{ // Essential directives that initialize vital configuration settings {"root", setup.Root}, - {"tls", setup.TLS}, + {"tls", setup.TLS}, // letsencrypt is set up just after tls {"bind", setup.BindHost}, // Other directives that don't create HTTP handlers diff --git a/caddy/letsencrypt/letsencrypt.go b/caddy/letsencrypt/letsencrypt.go index b060afa7..eb8e83f8 100644 --- a/caddy/letsencrypt/letsencrypt.go +++ b/caddy/letsencrypt/letsencrypt.go @@ -40,10 +40,16 @@ import ( // Activate returns the updated list of configs, since // some may have been appended, for example, to redirect // plaintext HTTP requests to their HTTPS counterpart. +// This function only appends; it does not prepend or splice. func Activate(configs []server.Config) ([]server.Config, error) { // just in case previous caller forgot... Deactivate() + // TODO: All the output the end user should see when running caddy is something + // simple like "Setting up HTTPS..." (and maybe 'done' at the end of the line when finished). + // In other words, hide all the other logging except for on errors. Or maybe + // have a place to put those logs. + // reset cached ocsp statuses from any previous activations ocspStatus = make(map[*[]byte]int) From 9a4e26a518dc5f929b3d35add80b302ff7734bc2 Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Sun, 1 Nov 2015 10:58:58 -0700 Subject: [PATCH 64/73] letsencrypt: Don't store KeyFile as field in user; staying consistent --- caddy/letsencrypt/user.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/caddy/letsencrypt/user.go b/caddy/letsencrypt/user.go index 1e1ab2c4..4d4d4829 100644 --- a/caddy/letsencrypt/user.go +++ b/caddy/letsencrypt/user.go @@ -20,7 +20,6 @@ import ( type User struct { Email string Registration *acme.RegistrationResource - KeyFile string key *rsa.PrivateKey } @@ -64,7 +63,7 @@ func getUser(email string) (User, error) { } // load their private key - user.key, err = loadRSAPrivateKey(user.KeyFile) + user.key, err = loadRSAPrivateKey(storage.UserKeyFile(email)) if err != nil { return user, err } @@ -82,8 +81,7 @@ func saveUser(user User) error { } // save private key file - user.KeyFile = storage.UserKeyFile(user.Email) - err = saveRSAPrivateKey(user.key, user.KeyFile) + err = saveRSAPrivateKey(user.key, storage.UserKeyFile(user.Email)) if err != nil { return err } From cac58eaab9e5cb06e3532f46c6697ebeea169eff Mon Sep 17 00:00:00 2001 From: xenolf Date: Mon, 2 Nov 2015 01:41:02 +0100 Subject: [PATCH 65/73] Update to latest lego changes --- caddy/letsencrypt/letsencrypt.go | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/caddy/letsencrypt/letsencrypt.go b/caddy/letsencrypt/letsencrypt.go index eb8e83f8..35b24d78 100644 --- a/caddy/letsencrypt/letsencrypt.go +++ b/caddy/letsencrypt/letsencrypt.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "io/ioutil" + "log" "net/http" "os" "strings" @@ -83,9 +84,11 @@ func Activate(configs []server.Config) ([]server.Config, error) { } // client is ready, so let's get free, trusted SSL certificates! yeah! - certificates, err := obtainCertificates(client, serverConfigs) - if err != nil { - return configs, errors.New("error getting certs: " + err.Error()) + certificates, failures := obtainCertificates(client, serverConfigs) + if len(failures) > 0 { + for k, v := range failures { + log.Printf("[%s] Failed to get a certificate: %s", k, v) + } } // ... that's it. save the certs, keys, and metadata files to disk @@ -234,19 +237,14 @@ func newClient(leEmail string) (*acme.Client, error) { // obtainCertificates obtains certificates from the CA server for // the configurations in serverConfigs using client. -func obtainCertificates(client *acme.Client, serverConfigs []*server.Config) ([]acme.CertificateResource, error) { +func obtainCertificates(client *acme.Client, serverConfigs []*server.Config) ([]acme.CertificateResource, map[string]error) { // collect all the hostnames into one slice var hosts []string for _, cfg := range serverConfigs { hosts = append(hosts, cfg.Host) } - certificates, err := client.ObtainCertificates(hosts, true) - if err != nil { - return nil, errors.New("error obtaining certs: " + err.Error()) - } - - return certificates, nil + return client.ObtainCertificates(hosts, true) } // saveCertificates saves each certificate resource to disk. This From 2712dcd1f5392e7a22843614ea11b9cc57b49e3b Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Sun, 1 Nov 2015 19:01:22 -0700 Subject: [PATCH 66/73] tls: If port unspecified and user provides cert+key, use 443 --- caddy/setup/tls.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/caddy/setup/tls.go b/caddy/setup/tls.go index 89d6e467..8a726950 100644 --- a/caddy/setup/tls.go +++ b/caddy/setup/tls.go @@ -21,6 +21,7 @@ func TLS(c *Controller) (middleware.Middleware, error) { switch len(args) { case 1: c.TLS.LetsEncryptEmail = args[0] + // user can force-disable LE activation this way if c.TLS.LetsEncryptEmail == "off" { c.TLS.Enabled = false @@ -28,6 +29,13 @@ func TLS(c *Controller) (middleware.Middleware, error) { case 2: c.TLS.Certificate = args[0] c.TLS.Key = args[1] + + // manual HTTPS configuration without port specified should be + // served on the HTTPS port; that is what user would expect, and + // makes it consistent with how the letsencrypt package works. + if c.Port == "" { + c.Port = "https" + } default: return nil, c.ArgErr() } From be0fb0053d8523a8781f8e8dd43690caf4777dc6 Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Mon, 2 Nov 2015 11:06:42 -0700 Subject: [PATCH 67/73] letsencrypt: Re-prompt user if obtaining certs fails due to updated SA --- caddy/letsencrypt/letsencrypt.go | 31 +++++++++++++++++++++++++++---- caddy/letsencrypt/maintain.go | 13 +++++++++++-- caddy/letsencrypt/user.go | 11 +++++++---- 3 files changed, 45 insertions(+), 10 deletions(-) diff --git a/caddy/letsencrypt/letsencrypt.go b/caddy/letsencrypt/letsencrypt.go index 35b24d78..fa19a81f 100644 --- a/caddy/letsencrypt/letsencrypt.go +++ b/caddy/letsencrypt/letsencrypt.go @@ -7,7 +7,6 @@ import ( "encoding/json" "errors" "io/ioutil" - "log" "net/http" "os" "strings" @@ -84,11 +83,35 @@ func Activate(configs []server.Config) ([]server.Config, error) { } // client is ready, so let's get free, trusted SSL certificates! yeah! + Obtain: certificates, failures := obtainCertificates(client, serverConfigs) if len(failures) > 0 { - for k, v := range failures { - log.Printf("[%s] Failed to get a certificate: %s", k, v) + // Build an error string to return, using all the failures in the list. + var errMsg string + + // An agreement error means we need to prompt the user (once) with updated terms + // while they're still here. + var promptedUpdatedTerms bool + + for domain, obtainErr := range failures { + // If the failure was simply because the terms have changed, re-prompt and re-try + if tosErr, ok := obtainErr.(acme.TOSError); ok && !promptedUpdatedTerms { + Agreed = promptUserAgreement(tosErr.Detail, true) // TODO: Use latest URL + promptedUpdatedTerms = true + if Agreed { + err := client.AgreeToTOS() + if err != nil { + return configs, errors.New("error agreeing to updated terms: " + err.Error()) + } + goto Obtain + } + } + + // If user did not agree or it was any other kind of error, just append to the list of errors + errMsg += "[" + domain + "] failed to get certificate: " + obtainErr.Error() + "\n" } + + return configs, errors.New(errMsg) } // ... that's it. save the certs, keys, and metadata files to disk @@ -213,7 +236,7 @@ func newClient(leEmail string) (*acme.Client, error) { leUser.Registration = reg if !Agreed && reg.TosURL == "" { - Agreed = promptUserAgreement("", false) // TODO + Agreed = promptUserAgreement(saURL, false) // TODO - latest URL } if !Agreed && reg.TosURL == "" { return nil, errors.New("user must agree to terms") diff --git a/caddy/letsencrypt/maintain.go b/caddy/letsencrypt/maintain.go index ae364749..7642a238 100644 --- a/caddy/letsencrypt/maintain.go +++ b/caddy/letsencrypt/maintain.go @@ -97,8 +97,8 @@ func renewCertificates(configs []server.Config) (int, []error) { // Directly convert it to days for the following checks. daysLeft := int(expTime.Sub(time.Now().UTC()).Hours() / 24) - // Renew with a week or less remaining. - if daysLeft <= 7 { + // Renew with two weeks or less remaining. + if daysLeft <= 14 { log.Printf("[INFO] There are %d days left on the certificate of %s. Trying to renew now.", daysLeft, cfg.Host) client, err := newClient("") // email not used for renewal if err != nil { @@ -127,8 +127,17 @@ func renewCertificates(configs []server.Config) (int, []error) { // Renew certificate. // TODO: revokeOld should be an option in the caddyfile // TODO: bundle should be an option in the caddyfile as well :) + Renew: newCertMeta, err := client.RenewCertificate(certMeta, true, true) if err != nil { + if _, ok := err.(acme.TOSError); ok { + err := client.AgreeToTOS() + if err != nil { + errs = append(errs, err) + } + goto Renew + } + time.Sleep(10 * time.Second) newCertMeta, err = client.RenewCertificate(certMeta, true, true) if err != nil { diff --git a/caddy/letsencrypt/user.go b/caddy/letsencrypt/user.go index 4d4d4829..7fae3bb4 100644 --- a/caddy/letsencrypt/user.go +++ b/caddy/letsencrypt/user.go @@ -146,9 +146,9 @@ func getEmail(cfg server.Config) string { reader := bufio.NewReader(stdin) fmt.Println("Your 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(" https://letsencrypt.org/documents/LE-SA-v1.0.1-July-27-2015.pdf") // TODO: Show current SA link + 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 lose the ability to recover your account.") + 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') @@ -167,10 +167,10 @@ func getEmail(cfg server.Config) string { // 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.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.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): ") } @@ -191,3 +191,6 @@ 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. const emptyEmail = "default" + +// TODO: Use latest +const saURL = "https://letsencrypt.org/documents/LE-SA-v1.0.1-July-27-2015.pdf" From b143bbdbaa073e5b229ae05d16cae18f6a359519 Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Mon, 2 Nov 2015 14:09:35 -0700 Subject: [PATCH 68/73] letsencrypt: Better logic for handling issuance failures This fixes a bug with the -agree flag --- caddy/letsencrypt/letsencrypt.go | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/caddy/letsencrypt/letsencrypt.go b/caddy/letsencrypt/letsencrypt.go index fa19a81f..7848e627 100644 --- a/caddy/letsencrypt/letsencrypt.go +++ b/caddy/letsencrypt/letsencrypt.go @@ -89,15 +89,16 @@ func Activate(configs []server.Config) ([]server.Config, error) { // Build an error string to return, using all the failures in the list. var errMsg string - // An agreement error means we need to prompt the user (once) with updated terms - // while they're still here. - var promptedUpdatedTerms bool + // If an error is because of updated SA, only prompt user for agreement once + var promptedForAgreement bool for domain, obtainErr := range failures { // If the failure was simply because the terms have changed, re-prompt and re-try - if tosErr, ok := obtainErr.(acme.TOSError); ok && !promptedUpdatedTerms { - Agreed = promptUserAgreement(tosErr.Detail, true) // TODO: Use latest URL - promptedUpdatedTerms = true + if tosErr, ok := obtainErr.(acme.TOSError); ok { + if !Agreed && !promptedForAgreement { + Agreed = promptUserAgreement(tosErr.Detail, true) // TODO: Use latest URL + promptedForAgreement = true + } if Agreed { err := client.AgreeToTOS() if err != nil { From d18cf12f148f734008cd9451fdcb7c1a16d823fb Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Mon, 2 Nov 2015 19:27:23 -0700 Subject: [PATCH 69/73] letsencrypt: Fixed renewals By chaining in a middleware handler and using newly exposed hooks from the acme package, we're able to proxy ACME requests on port 443 to the ACME client listening on a different port. --- caddy/letsencrypt/handler.go | 67 ++++++++++++++++++++++++++++++++ caddy/letsencrypt/letsencrypt.go | 34 +++++++++++++--- caddy/letsencrypt/maintain.go | 47 ++++++++++++++++------ 3 files changed, 132 insertions(+), 16 deletions(-) create mode 100644 caddy/letsencrypt/handler.go diff --git a/caddy/letsencrypt/handler.go b/caddy/letsencrypt/handler.go new file mode 100644 index 00000000..c97d47df --- /dev/null +++ b/caddy/letsencrypt/handler.go @@ -0,0 +1,67 @@ +package letsencrypt + +import ( + "crypto/tls" + "net/http" + "net/http/httputil" + "net/url" + "sync" + "sync/atomic" + + "github.com/mholt/caddy/middleware" +) + +// Handler is a Caddy middleware that can proxy ACME requests +// to the real ACME endpoint. This is necessary to renew certificates +// while the server is running. Obviously, a site served on port +// 443 (HTTPS) binds to that port, so another listener created by +// our acme client can't bind successfully and solve the challenge. +// Thus, we chain this handler in so that it can, when activated, +// proxy ACME requests to an ACME client listening on an alternate +// port. +type Handler struct { + sync.Mutex // protects the ChallengePath property + Next middleware.Handler + ChallengeActive int32 // use sync/atomic for speed to set/get this flag + ChallengePath string // the exact request path to match before proxying +} + +// ServeHTTP is basically a no-op unless an ACME challenge is active on this host +// and the request path matches the expected path exactly. +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) (int, error) { + // Only if challenge is active + if atomic.LoadInt32(&h.ChallengeActive) == 1 { + h.Lock() + path := h.ChallengePath + h.Unlock() + + // Request path must be correct; if so, proxy to ACME client + if r.URL.Path == path { + upstream, err := url.Parse("https://" + r.Host + ":" + alternatePort) + if err != nil { + return http.StatusInternalServerError, err + } + proxy := httputil.NewSingleHostReverseProxy(upstream) + proxy.Transport = &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // client uses self-signed cert + } + proxy.ServeHTTP(w, r) + return 0, nil + } + } + + return h.Next.ServeHTTP(w, r) +} + +// ChallengeOn enables h to proxy ACME requests. +func (h *Handler) ChallengeOn(challengePath string) { + h.Lock() + h.ChallengePath = challengePath + h.Unlock() + atomic.StoreInt32(&h.ChallengeActive, 1) +} + +// ChallengeOff disables ACME proxying from this h. +func (h *Handler) ChallengeOff(success bool) { + atomic.StoreInt32(&h.ChallengeActive, 0) +} diff --git a/caddy/letsencrypt/letsencrypt.go b/caddy/letsencrypt/letsencrypt.go index 7848e627..1b73fb6e 100644 --- a/caddy/letsencrypt/letsencrypt.go +++ b/caddy/letsencrypt/letsencrypt.go @@ -82,7 +82,7 @@ func Activate(configs []server.Config) ([]server.Config, error) { return configs, errors.New("error creating client: " + err.Error()) } - // client is ready, so let's get free, trusted SSL certificates! yeah! + // client is ready, so let's get free, trusted SSL certificates! Obtain: certificates, failures := obtainCertificates(client, serverConfigs) if len(failures) > 0 { @@ -128,7 +128,7 @@ func Activate(configs []server.Config) ([]server.Config, error) { } // renew all certificates that need renewal - renewCertificates(configs) + renewCertificates(configs, false) // keep certificates renewed and OCSP stapling updated go maintainAssets(configs, stopChan) @@ -167,8 +167,8 @@ func configQualifies(cfg server.Config, allConfigs []server.Config) bool { cfg.Host != "" && cfg.Host != "0.0.0.0" && cfg.Host != "::1" && - !strings.HasPrefix(cfg.Host, "127.") && - // TODO: Also exclude 10.* and 192.168.* addresses? + !strings.HasPrefix(cfg.Host, "127.") && // to use a boulder on your own machine, add fake domain to hosts file + // not excluding 10.* and 192.168.* hosts for possibility of running internal Boulder instance // make sure an HTTPS version of this config doesn't exist in the list already !hostHasOtherScheme(cfg.Host, "https", allConfigs) @@ -215,6 +215,14 @@ func existingCertAndKey(host string) bool { // disk (if already exists) or created new and registered via ACME // and saved to the file system for next time. func newClient(leEmail string) (*acme.Client, error) { + return newClientPort(leEmail, exposePort) +} + +// newClientPort does the same thing as newClient, except it creates a +// new client with a custom port used for ACME transactions instead of +// the default port. This is important if the default port is already in +// use or is not exposed to the public, etc. +func newClientPort(leEmail, port string) (*acme.Client, error) { // Look up or create the LE user account leUser, err := getUser(leEmail) if err != nil { @@ -222,7 +230,7 @@ func newClient(leEmail string) (*acme.Client, error) { } // The client facilitates our communication with the CA server. - client, err := acme.NewClient(CAUrl, &leUser, rsaKeySizeToUse, exposePort) + client, err := acme.NewClient(CAUrl, &leUser, rsaKeySizeToUse, port) if err != nil { return nil, err } @@ -325,6 +333,17 @@ func autoConfigure(cfg *server.Config, allConfigs []server.Config) []server.Conf cfg.Port = "https" } + // Chain in ACME middleware proxy if we use up the SSL port + if cfg.Port == "https" || cfg.Port == "443" { + handler := new(Handler) + mid := func(next middleware.Handler) middleware.Handler { + handler.Next = next + return handler + } + cfg.Middleware["/"] = append(cfg.Middleware["/"], mid) + acmeHandlers[cfg.Host] = handler + } + // Set up http->https redirect as long as there isn't already // a http counterpart in the configs if !hostHasOtherScheme(cfg.Host, "http", allConfigs) { @@ -440,6 +459,11 @@ const ( // then port 443 must be forwarded to exposePort. exposePort = "443" + // If port 443 is in use by a Caddy server instance, then this is + // port on which the acme client will solve challenges. (Whatever is + // listening on port 443 must proxy ACME requests to this port.) + alternatePort = "5033" + // How often to check certificates for renewal. renewInterval = 24 * time.Hour diff --git a/caddy/letsencrypt/maintain.go b/caddy/letsencrypt/maintain.go index 7642a238..62c71428 100644 --- a/caddy/letsencrypt/maintain.go +++ b/caddy/letsencrypt/maintain.go @@ -33,15 +33,17 @@ func maintainAssets(configs []server.Config, stopChan chan struct{}) { for { select { case <-renewalTicker.C: - if n, errs := renewCertificates(configs); len(errs) > 0 { + n, errs := renewCertificates(configs, true) + if len(errs) > 0 { for _, err := range errs { log.Printf("[ERROR] cert renewal: %v\n", err) } - if n > 0 && OnChange != nil { - err := OnChange() - if err != nil { - log.Printf("[ERROR] onchange after cert renewal: %v\n", err) - } + } + // even if there was an error, some renewals may have succeeded + if n > 0 && OnChange != nil { + err := OnChange() + if err != nil { + log.Printf("[ERROR] onchange after cert renewal: %v\n", err) } } case <-ocspTicker.C: @@ -69,11 +71,20 @@ func maintainAssets(configs []server.Config, stopChan chan struct{}) { // through this function; all changes happen directly on disk. // It returns the number of certificates renewed and any errors // that occurred. It only performs a renewal if necessary. -func renewCertificates(configs []server.Config) (int, []error) { +// If useCustomPort is true, a custom port will be used, and +// whatever is listening at 443 better proxy ACME requests to it. +// Otherwise, the acme package will create its own listener on 443. +func renewCertificates(configs []server.Config, useCustomPort bool) (int, []error) { log.Print("[INFO] Processing certificate renewals...") var errs []error var n int + defer func() { + // reset these so as to not interfere with other challenges + acme.OnSimpleHTTPStart = nil + acme.OnSimpleHTTPEnd = nil + }() + for _, cfg := range configs { // Host must be TLS-enabled and have existing assets managed by LE if !cfg.TLS.Enabled || !existingCertAndKey(cfg.Host) { @@ -100,7 +111,12 @@ func renewCertificates(configs []server.Config) (int, []error) { // Renew with two weeks or less remaining. if daysLeft <= 14 { log.Printf("[INFO] There are %d days left on the certificate of %s. Trying to renew now.", daysLeft, cfg.Host) - client, err := newClient("") // email not used for renewal + var client *acme.Client + if useCustomPort { + client, err = newClientPort("", alternatePort) // email not used for renewal + } else { + client, err = newClient("") + } if err != nil { errs = append(errs, err) continue @@ -124,6 +140,10 @@ func renewCertificates(configs []server.Config) (int, []error) { certMeta.Certificate = certBytes certMeta.PrivateKey = privBytes + // Tell the handler to accept and proxy acme request in order to solve challenge + acme.OnSimpleHTTPStart = acmeHandlers[cfg.Host].ChallengeOn + acme.OnSimpleHTTPEnd = acmeHandlers[cfg.Host].ChallengeOff + // Renew certificate. // TODO: revokeOld should be an option in the caddyfile // TODO: bundle should be an option in the caddyfile as well :) @@ -148,11 +168,16 @@ func renewCertificates(configs []server.Config) (int, []error) { saveCertsAndKeys([]acme.CertificateResource{newCertMeta}) n++ - } else if daysLeft <= 14 { - // Warn on 14 days remaining - log.Printf("[WARN] There are %d days left on the certificate for %s. Will renew when 7 days remain.\n", daysLeft, cfg.Host) + } else if daysLeft <= 30 { + // Warn on 30 days remaining. TODO: Just do this once... + log.Printf("[WARN] There are %d days left on the certificate for %s. Will renew when 14 days remain.\n", daysLeft, cfg.Host) } } return n, errs } + +// acmeHandlers is a map of host to ACME handler. These +// are used to proxy ACME requests to the ACME client +// when port 443 is in use. +var acmeHandlers = make(map[string]*Handler) From c3e64636766716d996de3689f71c6a8dd09fee9f Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Mon, 2 Nov 2015 19:27:42 -0700 Subject: [PATCH 70/73] A few comments, slight tweaks --- caddy/caddy.go | 2 +- caddy/config.go | 1 + caddy/helpers.go | 6 +++--- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/caddy/caddy.go b/caddy/caddy.go index 5d3785e1..f92666f0 100644 --- a/caddy/caddy.go +++ b/caddy/caddy.go @@ -39,7 +39,7 @@ var ( // The name and version of the application. AppName, AppVersion string - // If true, initialization will not show any output. + // If true, initialization will not show any informative output. Quiet bool // DefaultInput is the default configuration to use when config input is empty or missing. diff --git a/caddy/config.go b/caddy/config.go index 4bf111a1..0b14ca59 100644 --- a/caddy/config.go +++ b/caddy/config.go @@ -278,6 +278,7 @@ func arrangeBindings(allConfigs []server.Config) (Group, error) { func resolveAddr(conf server.Config) (resolvAddr *net.TCPAddr, warnErr error, fatalErr error) { bindHost := conf.BindHost + // TODO: Do we even need the port? Maybe we just need to look up the host. resolvAddr, warnErr = net.ResolveTCPAddr("tcp", net.JoinHostPort(bindHost, conf.Port)) if warnErr != nil { // Most likely the host lookup failed or the port is unknown diff --git a/caddy/helpers.go b/caddy/helpers.go index c606f941..66446a6c 100644 --- a/caddy/helpers.go +++ b/caddy/helpers.go @@ -16,9 +16,9 @@ func init() { letsencrypt.OnChange = func() error { return Restart(nil) } } -// isLocalhost returns true if the string looks explicitly like a localhost address. -func isLocalhost(s string) bool { - return s == "localhost" || s == "::1" || strings.HasPrefix(s, "127.") +// isLocalhost returns true if host looks explicitly like a localhost address. +func isLocalhost(host string) bool { + return host == "localhost" || host == "::1" || strings.HasPrefix(host, "127.") } // checkFdlimit issues a warning if the OS max file descriptors is below a recommended minimum. From 0970c058f7a7aedb476120a288214a35c396d1a5 Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Mon, 2 Nov 2015 20:54:38 -0700 Subject: [PATCH 71/73] tls: Repair from messy merge --- caddy/setup/tls.go | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/caddy/setup/tls.go b/caddy/setup/tls.go index 1345c11c..7519202b 100644 --- a/caddy/setup/tls.go +++ b/caddy/setup/tls.go @@ -9,17 +9,12 @@ import ( ) func TLS(c *Controller) (middleware.Middleware, error) { - if c.Port != "http" { - c.TLS.Enabled = true - } else { - log.Printf("Warning: TLS disabled for %s://%s. To force TLS over the plaintext HTTP port, "+ - "specify port 80 explicitly (https://%s:80).", c.Port, c.Host, c.Host) - } - if c.Port == "http" { c.TLS.Enabled = false log.Printf("Warning: TLS disabled for %s://%s. To force TLS over the plaintext HTTP port, "+ "specify port 80 explicitly (https://%s:80).", c.Port, c.Host, c.Host) + } else { + c.TLS.Enabled = true // they had a tls directive, so assume it's on unless we confirm otherwise later } for c.Next() { From 9905f48c8e71b450d9b0df0a1149a912de277ce9 Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Mon, 2 Nov 2015 20:56:13 -0700 Subject: [PATCH 72/73] Update changelog and readme --- dist/CHANGES.txt | 18 +++++++++++++++++- dist/README.txt | 2 +- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/dist/CHANGES.txt b/dist/CHANGES.txt index 6a2c201c..9cc16ab8 100644 --- a/dist/CHANGES.txt +++ b/dist/CHANGES.txt @@ -1,7 +1,23 @@ CHANGES - +0.8 beta +- Let's Encrypt (free, automatic, fully-managed HTTPS for your sites) +- Graceful restarts (for POSIX-compatible systems) +- Major internal refactoring to allow use of Caddy as library - New directive 'mime' to customize Content-Type based on file extension +- New -accept flag to accept Let's Encrypt SA without prompt +- New -ca flag to customize ACME CA server URL +- New -revoke flag to revoke a certificate +- browse: Render filenames with multiple whitespace properly +- markdown: Include Last-Modified header in response +- startup, shutdown: Better Windows support +- templates: Bug fix for .Host when port is absent +- templates: Include Last-Modified header in response +- templates: Support for custom delimiters +- tls: For non-local hosts, default port is now 443 unless specified +- tls: Force-disable HTTPS +- tls: Specify Let's Encrypt email address +- Many, many more tests and numerous bug fixes and improvements 0.7.6 (September 28, 2015) diff --git a/dist/README.txt b/dist/README.txt index de3fde6a..047e1955 100644 --- a/dist/README.txt +++ b/dist/README.txt @@ -1,4 +1,4 @@ -CADDY 0.7.6 +CADDY 0.8 beta Website https://caddyserver.com From 866427491ca9243b5d4793308439a4e471c93daf Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Mon, 2 Nov 2015 21:02:35 -0700 Subject: [PATCH 73/73] Forgot something --- dist/CHANGES.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/dist/CHANGES.txt b/dist/CHANGES.txt index 9cc16ab8..623c5cba 100644 --- a/dist/CHANGES.txt +++ b/dist/CHANGES.txt @@ -6,6 +6,7 @@ CHANGES - Major internal refactoring to allow use of Caddy as library - New directive 'mime' to customize Content-Type based on file extension - New -accept flag to accept Let's Encrypt SA without prompt +- New -email flag to customize default email used for ACME transactions - New -ca flag to customize ACME CA server URL - New -revoke flag to revoke a certificate - browse: Render filenames with multiple whitespace properly