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
<gpgKeyId>.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
This commit is contained in:
mpl 2016-12-18 01:24:15 +01:00
parent d29e05d610
commit 620388bd57
6 changed files with 255 additions and 56 deletions

View File

@ -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://")

View File

@ -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)")

View File

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

View File

@ -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, "")

View File

@ -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.

View File

@ -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 "<gpgKeyId>.camlistore.net",
// where <gpgKeyId> 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()
}