mirror of https://github.com/perkeep/perkeep.git
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:
parent
d29e05d610
commit
620388bd57
|
@ -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://")
|
||||
|
|
|
@ -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)")
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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, "")
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue