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() }