From 620388bd57b32e84c049d9dc57adc46d0e129835 Mon Sep 17 00:00:00 2001 From: mpl Date: Sun, 18 Dec 2016 01:24:15 +0100 Subject: [PATCH] server/camlistored: request a name in camlistore.net In order to use HTTPS, one must have a certificate, and one must have a domain name for which the certificate is valid. The first part is solved by the use of Let's Encrypt. For the second part, we want to provide to any Camlistore instance a name such as .camlistore.net, where gpgKeyId is the fingerprint of its GPG key. The DNS for camlistore.net agrees to add a record for that name if and only if the Camlistore instance can prove it owns the GPG key, as well as the IP address bound to that name in the DNS record. A protocol such as the above is already implemented in pkg/gpgchallenge. This CL: - uses the client-side of the gpgchallenge protocol in camlistored, so that it can claim a hostname in camlistore.net on startup (and then use that hostname when requesting a certificate from Let's Encrypt). - adds the configuration parameter "CamliNetIP" for the high-level config. This parameter specifies the IP address that camlistored will supply during the gpgpchallenge, so it can prove to the DNS server that we own this address. Fixes #722 Change-Id: I6bf4ec149b6dffd0ae93a6fa7bf208b2e8a05445 --- pkg/serverinit/env.go | 17 +-- pkg/serverinit/genconfig.go | 9 ++ pkg/serverinit/serverinit.go | 3 + pkg/types/clientconfig/config.go | 67 +++++----- pkg/types/serverconfig/config.go | 16 ++- server/camlistored/camlistored.go | 199 +++++++++++++++++++++++++++--- 6 files changed, 255 insertions(+), 56 deletions(-) diff --git a/pkg/serverinit/env.go b/pkg/serverinit/env.go index fd01e8c26..7f90953f9 100644 --- a/pkg/serverinit/env.go +++ b/pkg/serverinit/env.go @@ -58,17 +58,9 @@ func DefaultEnvConfig() (*Config, error) { return nil, err } - ipOrHost, _ := metadata.ExternalIP() - host, _ := metadata.InstanceAttributeValue("camlistore-hostname") - if host != "" && host != "localhost" { - ipOrHost = host - } - highConf := &serverconfig.Config{ Auth: auth, - BaseURL: fmt.Sprintf("https://%s", ipOrHost), HTTPS: true, - Listen: "0.0.0.0:443", Identity: keyId, IdentitySecretRing: secRing, GoogleCloudStorage: ":" + strings.TrimPrefix(blobBucket, "gs://"), @@ -80,6 +72,15 @@ func DefaultEnvConfig() (*Config, error) { SourceRoot: "/camlistore", } + externalIP, _ := metadata.ExternalIP() + hostName, _ := metadata.InstanceAttributeValue("camlistore-hostname") + if hostName != "" && hostName != "localhost" { + highConf.BaseURL = fmt.Sprintf("https://%s", hostName) + highConf.Listen = "0.0.0.0:443" + } else { + highConf.CamliNetIP = externalIP + } + // Detect a linked Docker MySQL container. It must have alias "mysqldb". if v := os.Getenv("MYSQLDB_PORT"); strings.HasPrefix(v, "tcp://") { hostPort := strings.TrimPrefix(v, "tcp://") diff --git a/pkg/serverinit/genconfig.go b/pkg/serverinit/genconfig.go index c75f2f7fc..3ef6a97f0 100644 --- a/pkg/serverinit/genconfig.go +++ b/pkg/serverinit/genconfig.go @@ -811,6 +811,15 @@ func (b *lowBuilder) genLowLevelPrefixes() error { func (b *lowBuilder) build() (*Config, error) { conf, low := b.high, b.low + if conf.CamliNetIP != "" { + if !conf.HTTPS { + return nil, errors.New("CamliNetIP requires HTTPS") + } + if conf.HTTPSCert != "" || conf.HTTPSKey != "" || conf.Listen != "" || conf.BaseURL != "" { + return nil, errors.New("CamliNetIP is mutually exclusive with HTTPSCert, HTTPSKey, Listen, and BaseURL.") + } + low["camliNetIP"] = conf.CamliNetIP + } if conf.HTTPS { if (conf.HTTPSCert != "") != (conf.HTTPSKey != "") { return nil, errors.New("Must set both httpsCert and httpsKey (or neither to generate a self-signed cert)") diff --git a/pkg/serverinit/serverinit.go b/pkg/serverinit/serverinit.go index cb9da3432..9dc1d4395 100644 --- a/pkg/serverinit/serverinit.go +++ b/pkg/serverinit/serverinit.go @@ -555,6 +555,9 @@ func (config *Config) InstallHandlers(hi HandlerInstaller, baseURL string, reind } for prefix, vei := range prefixes { + if prefix == "_knownkeys" { + continue + } if !strings.HasPrefix(prefix, "/") { exitFailure("prefix %q doesn't start with /", prefix) } diff --git a/pkg/types/clientconfig/config.go b/pkg/types/clientconfig/config.go index 19decfabf..8b7164a86 100644 --- a/pkg/types/clientconfig/config.go +++ b/pkg/types/clientconfig/config.go @@ -79,13 +79,45 @@ func GenerateClientConfig(serverConfig jsonconfig.Obj) (*Config, error) { return missingConfig(param) } - listen := serverConfig.OptionalString("listen", "") - baseURL := serverConfig.OptionalString("baseURL", "") - if listen == "" { - listen = baseURL + // checking these early, so we can get to keyId + param = "prefixes" + prefixes := serverConfig.OptionalObject(param) + if len(prefixes) == 0 { + return missingConfig(param) } - if listen == "" { - return nil, errors.New("required value for 'listen' or 'baseURL' not found") + param = "/sighelper/" + sighelper := prefixes.OptionalObject(param) + if len(sighelper) == 0 { + return missingConfig(param) + } + param = "handlerArgs" + handlerArgs := sighelper.OptionalObject(param) + if len(handlerArgs) == 0 { + return missingConfig(param) + } + param = "keyId" + keyId := handlerArgs.OptionalString(param, "") + if keyId == "" { + return missingConfig(param) + } + + var listen, baseURL string + camliNetIP := serverConfig.OptionalString("camliNetIP", "") + if camliNetIP != "" { + listen = ":443" + // TODO(mpl): move the camliNetDomain const from camlistored.go + // to somewhere importable, so we can use it here. but later. + camliNetDomain := "camlistore.net" + baseURL = fmt.Sprintf("https://%s.%s/", keyId, camliNetDomain) + } else { + listen = serverConfig.OptionalString("listen", "") + baseURL = serverConfig.OptionalString("baseURL", "") + if listen == "" { + listen = baseURL + } + if listen == "" { + return nil, errors.New("required value for 'listen' or 'baseURL' not found") + } } https := serverConfig.OptionalBool("https", false) @@ -112,29 +144,6 @@ func GenerateClientConfig(serverConfig jsonconfig.Obj) (*Config, error) { } trustedList = []string{sig} } - param = "prefixes" - prefixes := serverConfig.OptionalObject(param) - if len(prefixes) == 0 { - return missingConfig(param) - } - - param = "/sighelper/" - sighelper := prefixes.OptionalObject(param) - if len(sighelper) == 0 { - return missingConfig(param) - } - - param = "handlerArgs" - handlerArgs := sighelper.OptionalObject(param) - if len(handlerArgs) == 0 { - return missingConfig(param) - } - - param = "keyId" - keyId := handlerArgs.OptionalString(param, "") - if keyId == "" { - return missingConfig(param) - } param = "secretRing" secretRing := handlerArgs.OptionalString(param, "") diff --git a/pkg/types/serverconfig/config.go b/pkg/types/serverconfig/config.go index f051bd989..d87a6589f 100644 --- a/pkg/types/serverconfig/config.go +++ b/pkg/types/serverconfig/config.go @@ -28,9 +28,19 @@ import ( // serverinit.genLowLevelConfig, and used to configure the various // Camlistore components. type Config struct { - Auth string `json:"auth"` // auth scheme and values (ex: userpass:foo:bar). - BaseURL string `json:"baseURL,omitempty"` // Base URL the server advertizes. For when behind a proxy. - Listen string `json:"listen"` // address (of the form host|ip:port) on which the server will listen on. + Auth string `json:"auth"` // auth scheme and values (ex: userpass:foo:bar). + BaseURL string `json:"baseURL,omitempty"` // Base URL the server advertizes. For when behind a proxy. + Listen string `json:"listen"` // address (of the form host|ip:port) on which the server will listen on. + // CamliNetIP is the optional internet-facing IP address for this + // Camlistore instance. If set, a name in the camlistore.net domain for + // that IP address will be requested on startup. The obtained domain name + // will then be used as the host name in the base URL. + // For now, the protocol to get the name requires receiving a challenge + // on port 443. Also, this option implies HTTPS, and that the HTTPS + // certificate is obtained from Let's Encrypt. For these reasons, this + // option is mutually exclusive with BaseURL, Listen, HTTPSCert, and + // HTTPSKey. + CamliNetIP string `json:"camliNetIP"` Identity string `json:"identity"` // GPG identity. IdentitySecretRing string `json:"identitySecretRing"` // path to the secret ring file. // alternative source tree, to override the embedded ui and/or closure resources. diff --git a/server/camlistored/camlistored.go b/server/camlistored/camlistored.go index 318c2b62f..986dccc70 100644 --- a/server/camlistored/camlistored.go +++ b/server/camlistored/camlistored.go @@ -18,6 +18,8 @@ limitations under the License. package main // import "camlistore.org/server/camlistored" import ( + "crypto/tls" + "errors" "flag" "fmt" "io" @@ -36,6 +38,7 @@ import ( "camlistore.org/pkg/buildinfo" "camlistore.org/pkg/env" + "camlistore.org/pkg/gpgchallenge" "camlistore.org/pkg/httputil" "camlistore.org/pkg/netutil" "camlistore.org/pkg/osutil" @@ -105,6 +108,12 @@ var ( flagPollParent bool ) +// For getting a name in camlistore.net +const ( + camliNetDNS = "camnetdns.camlistore.org" + camliNetDomain = "camlistore.net" +) + // For logging on Google Cloud Logging when not running on Google Compute Engine // (for debugging). var ( @@ -225,7 +234,7 @@ func setupTLS(ws *webserver.Server, config *serverinit.Config, hostname string) HostPolicy: autocert.HostWhitelist(hostname), Cache: autocert.DirCache(osutil.DefaultLetsEncryptCache()), } - log.Print("TLS enabled, with Let's Encrypt") + log.Printf("TLS enabled, with Let's Encrypt for %v", hostname) ws.SetTLS(webserver.TLSSetup{ CertManager: m.GetCertificate, }) @@ -297,6 +306,162 @@ func handleSignals(shutdownc <-chan io.Closer) { } } +// listenForCamliNet prepares the TLS listener for both the GPG challenge, and +// for Let's Encrypt. It then starts listening and returns the baseURL derived from +// the hostname we should obtain from the GPG challenge. +func listenForCamliNet(ws *webserver.Server, config *serverinit.Config) (baseURL string, err error) { + camliNetIP := config.OptionalString("camliNetIP", "") + if camliNetIP == "" { + return "", errors.New("no camliNetIP") + } + if ip := net.ParseIP(camliNetIP); ip == nil { + return "", fmt.Errorf("camliNetIP value %q is not a valid IP address", camliNetIP) + } else if ip.To4() == nil { + // TODO: support IPv6 when GCE supports IPv6: https://code.google.com/p/google-compute-engine/issues/detail?id=8 + return "", errors.New("CamliNetIP should be an IPv4, as IPv6 is not yet supported on GCE") + } + challengeHostname := camliNetIP + gpgchallenge.SNISuffix + selfCert, selfKey, err := httputil.GenSelfTLS(challengeHostname) + if err != nil { + return "", fmt.Errorf("could not generate self-signed certificate: %v", err) + } + gpgchallengeCert, err := tls.X509KeyPair(selfCert, selfKey) + if err != nil { + return "", fmt.Errorf("could not load TLS certificate: %v", err) + } + _, keyId, err := keyRingAndId(config) + if err != nil { + return "", fmt.Errorf("could not get keyId for camliNet hostname: %v", err) + } + camliNetHostName := strings.ToLower(keyId + "." + camliNetDomain) + m := autocert.Manager{ + Prompt: autocert.AcceptTOS, + HostPolicy: autocert.HostWhitelist(camliNetHostName), + Cache: autocert.DirCache(osutil.DefaultLetsEncryptCache()), + } + getCertificate := func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { + if hello.ServerName == challengeHostname { + return &gpgchallengeCert, nil + } + return m.GetCertificate(hello) + } + log.Printf("TLS enabled, with Let's Encrypt for %v", camliNetHostName) + ws.SetTLS(webserver.TLSSetup{ + CertManager: getCertificate, + }) + // Since we're not going through setupTLS, we need to consume manually the 3 below + config.OptionalString("httpsCert", "") + config.OptionalString("httpsKey", "") + config.OptionalBool("https", true) + + err = ws.Listen(fmt.Sprintf(":%d", gpgchallenge.ClientChallengedPort)) + if err != nil { + return "", fmt.Errorf("Listen: %v", err) + } + return fmt.Sprintf("https://%s", camliNetHostName), nil +} + +// listen discovers the listen address, base URL, and hostname that the ws is +// going to use, sets up the TLS configuration, and starts listening. +// If camliNetIP, it also prepares for the GPG challenge, to register/acquire a +// name in the camlistore.net domain. +func listen(ws *webserver.Server, config *serverinit.Config) (baseURL string, err error) { + camliNetIP := config.OptionalString("camliNetIP", "") + if camliNetIP != "" { + return listenForCamliNet(ws, config) + } + + listen, baseURL := listenAndBaseURL(config) + hostname, err := certHostname(listen, baseURL) + if err != nil { + return "", fmt.Errorf("Bad baseURL or listen address: %v", err) + } + setupTLS(ws, config, hostname) + + err = ws.Listen(listen) + if err != nil { + return "", fmt.Errorf("Listen: %v", err) + } + if baseURL == "" { + baseURL = ws.ListenURL() + } + return baseURL, nil +} + +func keyRingAndId(config *serverinit.Config) (keyRing, keyId string, err error) { + prefixes := config.RequiredObject("prefixes") + if len(prefixes) == 0 { + return "", "", fmt.Errorf("no prefixes object in config") + } + sighelper := prefixes.OptionalObject("/sighelper/") + if len(sighelper) == 0 { + return "", "", fmt.Errorf("no sighelper object in prefixes") + } + handlerArgs := sighelper.OptionalObject("handlerArgs") + if len(handlerArgs) == 0 { + return "", "", fmt.Errorf("no handlerArgs object in sighelper") + } + keyId = handlerArgs.OptionalString("keyId", "") + if keyId == "" { + return "", "", fmt.Errorf("no keyId in sighelper") + } + keyRing = handlerArgs.OptionalString("secretRing", "") + if keyRing == "" { + return "", "", fmt.Errorf("no secretRing in sighelper") + } + return keyRing, keyId, nil +} + +// muxChallengeHandler initializes the gpgchallenge Client, and registers its +// handler with Camlistore's muxer. The returned Client can then be used right +// after Camlistore starts serving HTTPS connections. +func muxChallengeHandler(ws *webserver.Server, config *serverinit.Config) (*gpgchallenge.Client, error) { + camliNetIP := config.OptionalString("camliNetIP", "") + if camliNetIP == "" { + return nil, nil + } + if ip := net.ParseIP(camliNetIP); ip == nil { + return nil, fmt.Errorf("camliNetIP value %q is not a valid IP address", camliNetIP) + } + + keyRing, keyId, err := keyRingAndId(config) + if err != nil { + return nil, err + } + + cl, err := gpgchallenge.NewClient(keyRing, keyId, camliNetIP) + if err != nil { + return nil, fmt.Errorf("could not init gpgchallenge client: %v", err) + } + prefix, handler := cl.Handler() + if err != nil { + return nil, fmt.Errorf("could not get gpgchallenge client handler: %v", err) + } + ws.Handle(prefix, handler) + return cl, nil +} + +// requestHostName performs the GPG challenge to register/obtain a name in the +// camlistore.net domain. The acquired name should be ".camlistore.net", +// where is Camlistore's keyId. +// It also starts a goroutine that will rerun the challenge every hour, to keep +// the camlistore.net DNS server up to date. +func requestHostName(cl *gpgchallenge.Client) error { + if err := cl.Challenge(camliNetDNS); err != nil { + return err + } + + var repeatChallengeFn func() + repeatChallengeFn = func() { + if err := cl.Challenge(camliNetDNS); err != nil { + log.Printf("error with hourly DNS challenge: %v", err) + } + time.AfterFunc(time.Hour, repeatChallengeFn) + } + time.AfterFunc(time.Hour, repeatChallengeFn) + return nil +} + // listenAndBaseURL finds the configured, default, or inferred listen address // and base URL from the command-line flags and provided config. func listenAndBaseURL(config *serverinit.Config) (listen, baseURL string) { @@ -411,21 +576,9 @@ func Main(up chan<- struct{}, down <-chan struct{}) { } ws := webserver.New() - listen, baseURL := listenAndBaseURL(config) - - hostname, err := certHostname(listen, baseURL) + baseURL, err := listen(ws, config) if err != nil { - exitf("Bad baseURL or listen address: %v", err) - } - setupTLS(ws, config, hostname) - - err = ws.Listen(listen) - if err != nil { - exitf("Listen: %v", err) - } - - if baseURL == "" { - baseURL = ws.ListenURL() + exitf("Error starting webserver: %v", err) } shutdownCloser, err := config.InstallHandlers(ws, baseURL, *flagReindex, nil) @@ -434,6 +587,21 @@ func Main(up chan<- struct{}, down <-chan struct{}) { } shutdownc <- shutdownCloser + challengeClient, err := muxChallengeHandler(ws, config) + if err != nil { + exitf("Error registering challenge client with Camlistore muxer: %v", err) + } + + go ws.Serve() + + if challengeClient != nil { + // TODO(mpl): we should technically wait for the above ws.Serve + // to be ready, otherwise we're racy. Should we care? + if err := requestHostName(challengeClient); err != nil { + exitf("Could not register on camlistore.net: %v", err) + } + } + urlToOpen := baseURL if !isNewConfig { // user may like to configure the server at the initial startup, @@ -445,7 +613,6 @@ func Main(up chan<- struct{}, down <-chan struct{}) { go osutil.OpenURL(urlToOpen) } - go ws.Serve() if flagPollParent { osutil.DieOnParentDeath() }