diff --git a/README.md b/README.md index 00ce417b..99590798 100644 --- a/README.md +++ b/README.md @@ -42,10 +42,11 @@ When using the standard `--path` option, all certificates and account configurat #### Sudo The CLI does not require root permissions but needs to bind to port 80 and 443 for certain challenges. -To run the CLI without sudo, you have two options: +To run the CLI without sudo, you have three options: - Use setcap 'cap_net_bind_service=+ep' /path/to/program - Pass the `--http` or/and the `--tls` option and specify a custom port to bind to. In this case you have to forward port 80/443 to these custom ports (see [Port Usage](#port-usage)). +- Pass the `--webport` option and specify the path to your webroot folder. In this case the challenge will be written in a file in `.well-known/acme-challenge/` inside your webroot. #### Port Usage By default lego assumes it is able to bind to ports 80 and 443 to solve challenges. @@ -87,6 +88,7 @@ GLOBAL OPTIONS: --rsa-key-size, -B "2048" Size of the RSA key. --path "${CWD}/.lego" Directory to use for storing the data --exclude, -x [--exclude option --exclude option] Explicitly disallow solvers by name from being used. Solvers: "http-01", "tls-sni-01". + --webroot Set the webroot folder to use for HTTP based challenges to write directly in a file in .well-known/acme-challenge --http Set the port and interface to use for HTTP based challenges to listen on. Supported: interface:port or :port --tls Set the port and interface to use for TLS based challenges to listen on. Supported: interface:port or :port --dns Enable the DNS challenge for solving using a provider. diff --git a/acme/client.go b/acme/client.go index 700aeab5..119e0d94 100644 --- a/acme/client.go +++ b/acme/client.go @@ -12,6 +12,7 @@ import ( "io/ioutil" "log" "net" + "os" "regexp" "strconv" "strings" @@ -116,6 +117,21 @@ func (c *Client) SetChallengeProvider(challenge Challenge, p ChallengeProvider) return nil } +// SetWebRoot specifies a custom folder path to be used for HTTP based challenges. +// If this option is used, lego will not bind any port to listen to, +// instead it will write the challenge in a file into path/.well-known/acme-challenge/ +func (c *Client) SetWebRoot(path string) error { + if _, err := os.Stat(path); os.IsNotExist(err) { + return err + } + + if chlng, ok := c.solvers[HTTP01]; ok { + chlng.(*httpChallenge).provider = &httpChallengeWebRoot{path: path} + } + + return nil +} + // SetHTTPAddress specifies a custom interface:port to be used for HTTP based challenges. // If this option is not used, the default port 80 and all interfaces will be used. // To only specify a port and no interface use the ":port" notation. diff --git a/acme/http_challenge_test.go b/acme/http_challenge_test.go index 9c33d7d0..ff41c209 100644 --- a/acme/http_challenge_test.go +++ b/acme/http_challenge_test.go @@ -3,6 +3,7 @@ package acme import ( "crypto/rsa" "io/ioutil" + "os" "strings" "testing" ) @@ -54,3 +55,48 @@ func TestHTTPChallengeInvalidPort(t *testing.T) { t.Errorf("Solve error: got %q, want suffix %q", err.Error(), want) } } + +func TestHTTPChallengeWebRoot(t *testing.T) { + privKey, _ := generatePrivateKey(rsakey, 512) + j := &jws{privKey: privKey.(*rsa.PrivateKey)} + clientChallenge := challenge{Type: HTTP01, Token: "http1"} + mockValidate := func(_ *jws, _, _ string, chlng challenge) error { + challengeFilePath := "webroot/.well-known/acme-challenge/" + chlng.Token + + if _, err := os.Stat(challengeFilePath); os.IsNotExist(err) { + t.Error("Challenge file was not created in webroot") + } + + data, err := ioutil.ReadFile(challengeFilePath) + if err != nil { + return err + } + dataStr := string(data) + + if dataStr != chlng.KeyAuthorization { + t.Errorf("Challenge file content: got %q, want %q", dataStr, chlng.KeyAuthorization) + } + + return nil + } + solver := &httpChallenge{jws: j, validate: mockValidate, provider: &httpChallengeWebRoot{path: "webroot"}} + + os.MkdirAll("webroot/.well-known/acme-challenge", 0777) + if err := solver.Solve(clientChallenge, "localhost:23457"); err != nil { + t.Errorf("Solve error: got %v, want nil", err) + } + defer os.RemoveAll("webroot") +} + +func TestHTTPChallengeWebRootInvalidPath(t *testing.T) { + privKey, _ := generatePrivateKey(rsakey, 128) + j := &jws{privKey: privKey.(*rsa.PrivateKey)} + clientChallenge := challenge{Type: HTTP01, Token: "http2"} + solver := &httpChallenge{jws: j, validate: stubValidate, provider: &httpChallengeWebRoot{path: "/invalid-path-123456"}} + + if err := solver.Solve(clientChallenge, "localhost:123456"); err == nil { + t.Errorf("Solve error: got %v, want error", err) + } else if want := "Could not write file in webroot"; !strings.Contains(err.Error(), want) { + t.Errorf("Solve error: got %q, want content %q", err.Error(), want) + } +} diff --git a/acme/http_challenge_webroot.go b/acme/http_challenge_webroot.go new file mode 100644 index 00000000..caece376 --- /dev/null +++ b/acme/http_challenge_webroot.go @@ -0,0 +1,34 @@ +package acme + +import ( + "fmt" + "io/ioutil" + "os" + "path" +) + +// httpChallengeWebRoot implements ChallengeProvider for `http-01` challenge +type httpChallengeWebRoot struct { + path string +} + +// Present makes the token available at `HTTP01ChallengePath(token)` +func (w *httpChallengeWebRoot) Present(domain, token, keyAuth string) error { + var err error + err = ioutil.WriteFile(path.Join(w.path, HTTP01ChallengePath(token)), []byte(keyAuth), 0777) + if err != nil { + return fmt.Errorf("Could not write file in webroot for HTTP challenge -> %v", err) + } + + return nil +} + +func (w *httpChallengeWebRoot) CleanUp(domain, token, keyAuth string) error { + var err error + err = os.Remove(path.Join(w.path, HTTP01ChallengePath(token))) + if err != nil { + return fmt.Errorf("Could not remove file in webroot after HTTP challenge -> %v", err) + } + + return nil +} diff --git a/cli.go b/cli.go index 3851c455..f0edccf5 100644 --- a/cli.go +++ b/cli.go @@ -102,6 +102,10 @@ func main() { Name: "exclude, x", Usage: "Explicitly disallow solvers by name from being used. Solvers: \"http-01\", \"tls-sni-01\".", }, + cli.StringFlag{ + Name: "webroot", + Usage: "Set the webroot folder to use for HTTP based challenges to write directly in a file in .well-known/acme-challenge", + }, cli.StringFlag{ Name: "http", Usage: "Set the port and interface to use for HTTP based challenges to listen on. Supported: interface:port or :port", diff --git a/cli_handlers.go b/cli_handlers.go index 2b5b3fc0..d9da8357 100644 --- a/cli_handlers.go +++ b/cli_handlers.go @@ -43,6 +43,9 @@ func setup(c *cli.Context) (*Configuration, *Account, *acme.Client) { client.ExcludeChallenges(conf.ExcludedSolvers()) } + if c.GlobalIsSet("webroot") { + client.SetWebRoot(c.GlobalString("webroot")) + } if c.GlobalIsSet("http") { client.SetHTTPAddress(c.GlobalString("http")) }