From 403d72b468a124eb7643743bc07b181ab9e2fa2c Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Mon, 7 Jan 2019 18:48:02 +0100 Subject: [PATCH] lnrpc: lets encrypt This commit enables lnd to request and renew a Let's Encrypt certificate. This certificate is used both for the grpc as well as the rest listeners. It allows clients to connect without having a copy of the (public) server certificate. Co-authored-by: Vegard Engen --- cmd/lncli/main.go | 18 ++++++++-- cmd/lncli/profile.go | 19 +++++++--- config.go | 17 +++++++-- lnd.go | 84 ++++++++++++++++++++++++++++++++++++++------ sample-lnd.conf | 14 ++++++++ server_test.go | 3 +- 6 files changed, 133 insertions(+), 22 deletions(-) diff --git a/cmd/lncli/main.go b/cmd/lncli/main.go index eb75fe91..12f67374 100644 --- a/cmd/lncli/main.go +++ b/cmd/lncli/main.go @@ -5,6 +5,7 @@ package main import ( + "crypto/tls" "fmt" "os" "path/filepath" @@ -74,13 +75,24 @@ func getClientConn(ctx *cli.Context, skipMacaroons bool) *grpc.ClientConn { fatal(fmt.Errorf("could not load global options: %v", err)) } - // Load the specified TLS certificate and build transport credentials - // with it. + // Load the specified TLS certificate. certPool, err := profile.cert() if err != nil { fatal(fmt.Errorf("could not create cert pool: %v", err)) } - creds := credentials.NewClientTLSFromCert(certPool, "") + + // Build transport credentials from the certificate pool. If there is no + // certificate pool, we expect the server to use a non-self-signed + // certificate such as a certificate obtained from Let's Encrypt. + var creds credentials.TransportCredentials + if certPool != nil { + creds = credentials.NewClientTLSFromCert(certPool, "") + } else { + // Fallback to the system pool. Using an empty tls config is an + // alternative to x509.SystemCertPool(). That call is not + // supported on Windows. + creds = credentials.NewTLS(&tls.Config{}) + } // Create a dial options array. opts := []grpc.DialOption{ diff --git a/cmd/lncli/profile.go b/cmd/lncli/profile.go index b1583f6d..5b1f496e 100644 --- a/cmd/lncli/profile.go +++ b/cmd/lncli/profile.go @@ -36,6 +36,10 @@ type profileEntry struct { // cert returns the profile's TLS certificate as a x509 certificate pool. func (e *profileEntry) cert() (*x509.CertPool, error) { + if e.TLSCert == "" { + return nil, nil + } + cp := x509.NewCertPool() if !cp.AppendCertsFromPEM([]byte(e.TLSCert)) { return nil, fmt.Errorf("credentials: failed to append " + @@ -113,11 +117,16 @@ func profileFromContext(ctx *cli.Context, store bool) (*profileEntry, error) { return nil, err } - // Load the certificate file now. We store it as plain PEM directly. - tlsCert, err := ioutil.ReadFile(tlsCertPath) - if err != nil { - return nil, fmt.Errorf("could not load TLS cert file %s: %v", - tlsCertPath, err) + // Load the certificate file now, if specified. We store it as plain PEM + // directly. + var tlsCert []byte + if lnrpc.FileExists(tlsCertPath) { + var err error + tlsCert, err = ioutil.ReadFile(tlsCertPath) + if err != nil { + return nil, fmt.Errorf("could not load TLS cert file "+ + "%s: %v", tlsCertPath, err) + } } // Now load and possibly encrypt the macaroon file. diff --git a/config.go b/config.go index 7df5adcd..395f8dd1 100644 --- a/config.go +++ b/config.go @@ -64,6 +64,8 @@ const ( defaultMaxLogFileSize = 10 defaultMinBackoff = time.Second defaultMaxBackoff = time.Hour + defaultLetsEncryptDirname = "letsencrypt" + defaultLetsEncryptPort = 80 defaultTorSOCKSPort = 9050 defaultTorDNSHost = "soa.nodes.lightning.directory" @@ -127,8 +129,9 @@ var ( defaultTowerDir = filepath.Join(defaultDataDir, defaultTowerSubDirname) - defaultTLSCertPath = filepath.Join(DefaultLndDir, defaultTLSCertFilename) - defaultTLSKeyPath = filepath.Join(DefaultLndDir, defaultTLSKeyFilename) + defaultTLSCertPath = filepath.Join(DefaultLndDir, defaultTLSCertFilename) + defaultTLSKeyPath = filepath.Join(DefaultLndDir, defaultTLSKeyFilename) + defaultLetsEncryptDir = filepath.Join(DefaultLndDir, defaultLetsEncryptDirname) defaultBtcdDir = btcutil.AppDataDir("btcd", false) defaultBtcdRPCCertFile = filepath.Join(defaultBtcdDir, "rpc.cert") @@ -179,6 +182,10 @@ type Config struct { MaxLogFileSize int `long:"maxlogfilesize" description:"Maximum logfile size in MB"` AcceptorTimeout time.Duration `long:"acceptortimeout" description:"Time after which an RPCAcceptor will time out and return false if it hasn't yet received a response"` + LetsEncryptDir string `long:"letsencryptdir" description:"The directory to store Let's Encrypt certificates within"` + LetsEncryptPort int `long:"letsencryptport" description:"The port on which lnd will listen for Let's Encrypt challenges. Let's Encrypt will always try to contact on port 80. Often non-root processes are not allowed to bind to ports lower than 1024. This configuration option allows a different port to be used, but must be used in combination with port forwarding from port 80."` + LetsEncryptDomain string `long:"letsencryptdomain" description:"Request a Let's Encrypt certificate for this domain. Note that the certicate is only requested and stored when the first rpc connection comes in."` + // We'll parse these 'raw' string arguments into real net.Addrs in the // loadConfig function. We need to expose the 'raw' strings so the // command line library can access them. @@ -318,6 +325,8 @@ func DefaultConfig() Config { DebugLevel: defaultLogLevel, TLSCertPath: defaultTLSCertPath, TLSKeyPath: defaultTLSKeyPath, + LetsEncryptDir: defaultLetsEncryptDir, + LetsEncryptPort: defaultLetsEncryptPort, LogDir: defaultLogDir, MaxLogFiles: defaultMaxLogFiles, MaxLogFileSize: defaultMaxLogFileSize, @@ -520,6 +529,9 @@ func ValidateConfig(cfg Config, usageMessage string) (*Config, error) { lndDir := CleanAndExpandPath(cfg.LndDir) if lndDir != DefaultLndDir { cfg.DataDir = filepath.Join(lndDir, defaultDataDirname) + cfg.LetsEncryptDir = filepath.Join( + lndDir, defaultLetsEncryptDirname, + ) cfg.TLSCertPath = filepath.Join(lndDir, defaultTLSCertFilename) cfg.TLSKeyPath = filepath.Join(lndDir, defaultTLSKeyFilename) cfg.LogDir = filepath.Join(lndDir, defaultLogDirname) @@ -558,6 +570,7 @@ func ValidateConfig(cfg Config, usageMessage string) (*Config, error) { cfg.DataDir = CleanAndExpandPath(cfg.DataDir) cfg.TLSCertPath = CleanAndExpandPath(cfg.TLSCertPath) cfg.TLSKeyPath = CleanAndExpandPath(cfg.TLSKeyPath) + cfg.LetsEncryptDir = CleanAndExpandPath(cfg.LetsEncryptDir) cfg.AdminMacPath = CleanAndExpandPath(cfg.AdminMacPath) cfg.ReadMacPath = CleanAndExpandPath(cfg.ReadMacPath) cfg.InvoiceMacPath = CleanAndExpandPath(cfg.InvoiceMacPath) diff --git a/lnd.go b/lnd.go index ddf88505..ad8cf0a9 100644 --- a/lnd.go +++ b/lnd.go @@ -23,6 +23,7 @@ import ( "github.com/btcsuite/btcwallet/wallet" proxy "github.com/grpc-ecosystem/grpc-gateway/runtime" "github.com/lightninglabs/neutrino" + "golang.org/x/crypto/acme/autocert" "google.golang.org/grpc" "google.golang.org/grpc/credentials" "gopkg.in/macaroon-bakery.v2/bakery" @@ -264,13 +265,15 @@ func Main(cfg *Config, lisCfg ListenerCfg, shutdownChan <-chan struct{}) error { defer cleanUp() // Only process macaroons if --no-macaroons isn't set. - tlsCfg, restCreds, restProxyDest, err := getTLSConfig(cfg) + tlsCfg, restCreds, restProxyDest, cleanUp, err := getTLSConfig(cfg) if err != nil { err := fmt.Errorf("unable to load TLS credentials: %v", err) ltndLog.Error(err) return err } + defer cleanUp() + serverCreds := credentials.NewTLS(tlsCfg) serverOpts := []grpc.ServerOption{grpc.Creds(serverCreds)} @@ -748,7 +751,7 @@ func Main(cfg *Config, lisCfg ListenerCfg, shutdownChan <-chan struct{}) error { // getTLSConfig returns a TLS configuration for the gRPC server and credentials // and a proxy destination for the REST reverse proxy. func getTLSConfig(cfg *Config) (*tls.Config, *credentials.TransportCredentials, - string, error) { + string, func(), error) { // Ensure we create TLS key and certificate if they don't exist. if !fileExists(cfg.TLSCertPath) && !fileExists(cfg.TLSKeyPath) { @@ -759,7 +762,7 @@ func getTLSConfig(cfg *Config) (*tls.Config, *credentials.TransportCredentials, cfg.TLSDisableAutofill, cert.DefaultAutogenValidity, ) if err != nil { - return nil, nil, "", err + return nil, nil, "", nil, err } rpcsLog.Infof("Done generating TLS certificates") } @@ -768,7 +771,7 @@ func getTLSConfig(cfg *Config) (*tls.Config, *credentials.TransportCredentials, cfg.TLSCertPath, cfg.TLSKeyPath, ) if err != nil { - return nil, nil, "", err + return nil, nil, "", nil, err } // We check whether the certifcate we have on disk match the IPs and @@ -782,7 +785,7 @@ func getTLSConfig(cfg *Config) (*tls.Config, *credentials.TransportCredentials, cfg.TLSExtraDomains, cfg.TLSDisableAutofill, ) if err != nil { - return nil, nil, "", err + return nil, nil, "", nil, err } } @@ -794,12 +797,12 @@ func getTLSConfig(cfg *Config) (*tls.Config, *credentials.TransportCredentials, err := os.Remove(cfg.TLSCertPath) if err != nil { - return nil, nil, "", err + return nil, nil, "", nil, err } err = os.Remove(cfg.TLSKeyPath) if err != nil { - return nil, nil, "", err + return nil, nil, "", nil, err } rpcsLog.Infof("Renewing TLS certificates...") @@ -809,7 +812,7 @@ func getTLSConfig(cfg *Config) (*tls.Config, *credentials.TransportCredentials, cfg.TLSDisableAutofill, cert.DefaultAutogenValidity, ) if err != nil { - return nil, nil, "", err + return nil, nil, "", nil, err } rpcsLog.Infof("Done renewing TLS certificates") @@ -818,14 +821,15 @@ func getTLSConfig(cfg *Config) (*tls.Config, *credentials.TransportCredentials, cfg.TLSCertPath, cfg.TLSKeyPath, ) if err != nil { - return nil, nil, "", err + return nil, nil, "", nil, err } } tlsCfg := cert.TLSConfFromCert(certData) + restCreds, err := credentials.NewClientTLSFromFile(cfg.TLSCertPath, "") if err != nil { - return nil, nil, "", err + return nil, nil, "", nil, err } restProxyDest := cfg.RPCListeners[0].String() @@ -841,7 +845,65 @@ func getTLSConfig(cfg *Config) (*tls.Config, *credentials.TransportCredentials, ) } - return tlsCfg, &restCreds, restProxyDest, nil + // If Let's Encrypt is enabled, instantiate autocert to request/renew + // the certificates. + cleanUp := func() {} + if cfg.LetsEncryptDomain != "" { + ltndLog.Infof("Using Let's Encrypt certificate for domain %v", + cfg.LetsEncryptDomain) + + manager := autocert.Manager{ + Cache: autocert.DirCache(cfg.LetsEncryptDir), + Prompt: autocert.AcceptTOS, + HostPolicy: autocert.HostWhitelist(cfg.LetsEncryptDomain), + } + + addr := fmt.Sprintf(":%v", cfg.LetsEncryptPort) + srv := &http.Server{ + Addr: addr, + Handler: manager.HTTPHandler(nil), + } + shutdownCompleted := make(chan struct{}) + cleanUp = func() { + err := srv.Shutdown(context.Background()) + if err != nil { + ltndLog.Errorf("Autocert listener shutdown "+ + " error: %v", err) + + return + } + <-shutdownCompleted + ltndLog.Infof("Autocert challenge listener stopped") + } + + go func() { + ltndLog.Infof("Autocert challenge listener started "+ + "at %v", addr) + + err := srv.ListenAndServe() + if err != http.ErrServerClosed { + ltndLog.Errorf("autocert http: %v", err) + } + close(shutdownCompleted) + }() + + getCertificate := func(h *tls.ClientHelloInfo) ( + *tls.Certificate, error) { + + lecert, err := manager.GetCertificate(h) + if err != nil { + ltndLog.Errorf("GetCertificate: %v", err) + return &certData, nil + } + + return lecert, err + } + + // The self-signed tls.cert remains available as fallback. + tlsCfg.GetCertificate = getCertificate + } + + return tlsCfg, &restCreds, restProxyDest, cleanUp, nil } // fileExists reports whether the named file or directory exists. diff --git a/sample-lnd.conf b/sample-lnd.conf index cb52ec8e..bfa79999 100644 --- a/sample-lnd.conf +++ b/sample-lnd.conf @@ -50,6 +50,20 @@ ; or want to expose the node at a domain. ; externalhosts=my-node-domain.com +; Sets the directory to store Let's Encrypt certificates within +; letsencryptdir=~/.lnd/letsencrypt + +; Sets the port on which lnd will listen for Let's Encrypt challenges. Let's +; Encrypt will always try to contact on port 80. Often non-root processes are +; not allowed to bind to ports lower than 1024. This configuration option allows +; a different port to be used, but must be used in combination with port +; forwarding from port 80. +; letsencryptport=8080 + +; Request a Let's Encrypt certificate for this domain. Note that the certicate +; is only requested and stored when the first rpc connection comes in. +; letsencryptdomain=example.com + ; Disable macaroon authentication. Macaroons are used are bearer credentials to ; authenticate all RPC access. If one wishes to opt out of macaroons, uncomment ; the line below. diff --git a/server_test.go b/server_test.go index d272fcf9..cfc1b253 100644 --- a/server_test.go +++ b/server_test.go @@ -119,10 +119,11 @@ func TestTLSAutoRegeneration(t *testing.T) { TLSKeyPath: keyPath, RPCListeners: rpcListeners, } - _, _, _, err = getTLSConfig(cfg) + _, _, _, cleanUp, err := getTLSConfig(cfg) if err != nil { t.Fatalf("couldn't retrieve TLS config") } + defer cleanUp() // Grab the certificate to test that getTLSConfig did its job correctly // and generated a new cert.