/* Copyright 2012 Google Inc. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package serverinit import ( "encoding/json" "errors" "fmt" "log" "net/url" "os" "path/filepath" "runtime" "strconv" "strings" "camlistore.org/pkg/blob" "camlistore.org/pkg/jsonconfig" "camlistore.org/pkg/jsonsign" "camlistore.org/pkg/osutil" "camlistore.org/pkg/types/serverconfig" "camlistore.org/pkg/wkfs" ) var ( tempDir = os.TempDir noMkdir bool // for tests to not call os.Mkdir ) type tlsOpts struct { httpsCert string httpsKey string } // genLowLevelConfig returns a low-level config from a high-level config. func genLowLevelConfig(conf *serverconfig.Config) (lowLevelConf *Config, err error) { b := &lowBuilder{ high: conf, low: jsonconfig.Obj{ "prefixes": make(map[string]interface{}), }, } return b.build() } // A lowBuilder builds a low-level config from a high-level config. type lowBuilder struct { high *serverconfig.Config // high-level config (input) low jsonconfig.Obj // low-level handler config (output) } // args is an alias for map[string]interface{} just to cut down on // noise below. But we take care to convert it back to // map[string]interface{} in the one place where we accept it. type args map[string]interface{} func (b *lowBuilder) addPrefix(at, handler string, a args) { v := map[string]interface{}{ "handler": handler, } if a != nil { v["handlerArgs"] = (map[string]interface{})(a) } b.low["prefixes"].(map[string]interface{})[at] = v } func (b *lowBuilder) hasPrefix(p string) bool { _, ok := b.low["prefixes"].(map[string]interface{})[p] return ok } func (b *lowBuilder) runIndex() bool { return b.high.RunIndex.Get() } func (b *lowBuilder) memoryIndex() bool { return b.high.MemoryIndex.Get() } // dbName returns which database to use for the provided user ("of"). // The user key be a key as describe in pkg/types/serverconfig/config.go's // description of DBNames: "index", "queue-sync-to-index", etc. func (b *lowBuilder) dbName(of string) string { if v, ok := b.high.DBNames[of]; ok && v != "" { return v } if of == "index" { if b.high.DBName != "" { return b.high.DBName } username := osutil.Username() if username == "" { envVar := "USER" if runtime.GOOS == "windows" { envVar += "NAME" } return "camlistore_index" } return "camli" + username } return "" } var errNoOwner = errors.New("no owner") // Error is errNoOwner if no identity configured func (b *lowBuilder) searchOwner() (br blob.Ref, err error) { if b.high.Identity == "" { return br, errNoOwner } entity, err := jsonsign.EntityFromSecring(b.high.Identity, b.high.IdentitySecretRing) if err != nil { return br, err } armoredPublicKey, err := jsonsign.ArmoredPublicKey(entity) if err != nil { return br, err } return blob.SHA1FromString(armoredPublicKey), nil } func (b *lowBuilder) addPublishedConfig(tlsO *tlsOpts) error { published := b.high.Publish for k, v := range published { if v.CamliRoot == "" { return fmt.Errorf("Missing \"camliRoot\" key in configuration for %s.", k) } if v.GoTemplate == "" { return fmt.Errorf("Missing \"goTemplate\" key in configuration for %s.", k) } appConfig := map[string]interface{}{ "camliRoot": v.CamliRoot, "cacheRoot": v.CacheRoot, "goTemplate": v.GoTemplate, } if v.HTTPSCert != "" && v.HTTPSKey != "" { // user can specify these directly in the publish section appConfig["httpsCert"] = v.HTTPSCert appConfig["httpsKey"] = v.HTTPSKey } else { // default to Camlistore parameters, if any if tlsO != nil { appConfig["httpsCert"] = tlsO.httpsCert appConfig["httpsKey"] = tlsO.httpsKey } } a := args{ "program": v.Program, "appConfig": appConfig, } if v.BaseURL != "" { a["baseURL"] = v.BaseURL } program := "publisher" if v.Program != "" { program = v.Program } a["program"] = program b.addPrefix(k, "app", a) } return nil } func (b *lowBuilder) addUIConfig() { args := map[string]interface{}{ "jsonSignRoot": "/sighelper/", "cache": "/cache/", } if b.high.SourceRoot != "" { args["sourceRoot"] = b.high.SourceRoot } var thumbCache map[string]interface{} if b.high.BlobPath != "" { thumbCache = map[string]interface{}{ "type": "kv", "file": filepath.Join(b.high.BlobPath, "thumbmeta.kv"), } } if thumbCache == nil { sorted, err := b.sortedStorage("ui_thumbcache") if err == nil { thumbCache = sorted } } if thumbCache != nil { args["scaledImage"] = thumbCache } b.addPrefix("/ui/", "ui", args) } func (b *lowBuilder) mongoIndexStorage(confStr, sortedType string) (map[string]interface{}, error) { dbName := b.dbName(sortedType) if dbName == "" { return nil, fmt.Errorf("no database name configured for sorted store %q", sortedType) } fields := strings.Split(confStr, "@") if len(fields) == 2 { host := fields[1] fields = strings.Split(fields[0], ":") if len(fields) == 2 { user, pass := fields[0], fields[1] return map[string]interface{}{ "type": "mongo", "host": host, "user": user, "password": pass, "database": dbName, }, nil } } return nil, errors.New("Malformed mongo config string; want form: \"user:password@host\"") } // parses "user@host:password", which you think would be easy, but we // documented this format without thinking about port numbers, so this // uses heuristics to guess what extra colons mean. func parseUserHostPass(v string) (user, host, password string, ok bool) { f := strings.SplitN(v, "@", 2) if len(f) != 2 { return } user = f[0] f = strings.Split(f[1], ":") if len(f) < 2 { return "", "", "", false } host = f[0] f = f[1:] if len(f) >= 2 { if _, err := strconv.ParseUint(f[0], 10, 16); err == nil { host = host + ":" + f[0] f = f[1:] } } password = strings.Join(f, ":") ok = true return } func (b *lowBuilder) dbIndexStorage(rdbms string, confStr string, sortedType string) (map[string]interface{}, error) { dbName := b.dbName(sortedType) if dbName == "" { return nil, fmt.Errorf("no database name configured for sorted store %q", sortedType) } user, host, password, ok := parseUserHostPass(confStr) if !ok { return nil, fmt.Errorf("Malformed %s config string. Want: \"user@host:password\"", rdbms) } return map[string]interface{}{ "type": rdbms, "host": host, "user": user, "password": password, "database": b.dbName(sortedType), }, nil } func (b *lowBuilder) sortedStorage(sortedType string) (map[string]interface{}, error) { if b.high.MySQL != "" { return b.dbIndexStorage("mysql", b.high.MySQL, sortedType) } if b.high.PostgreSQL != "" { return b.dbIndexStorage("postgres", b.high.PostgreSQL, sortedType) } if b.high.Mongo != "" { return b.mongoIndexStorage(b.high.Mongo, sortedType) } if sortedType != "index" { return nil, fmt.Errorf("TODO: finish SQLite & KVFile for non-index queues") } if b.high.SQLite != "" { return map[string]interface{}{ "type": "sqlite", "file": b.high.SQLite, }, nil } if b.high.KVFile != "" { return map[string]interface{}{ "type": "kv", "file": b.high.KVFile, }, nil } panic("indexArgs called when not in index mode") } func (b *lowBuilder) addS3Config(s3 string) error { f := strings.SplitN(s3, ":", 4) if len(f) < 3 { return errors.New(`genconfig: expected "s3" field to be of form "access_key_id:secret_access_key:bucket"`) } accessKey, secret, bucket := f[0], f[1], f[2] var hostname string if len(f) == 4 { hostname = f[3] } isPrimary := !b.hasPrefix("/bs/") s3Prefix := "" if isPrimary { s3Prefix = "/bs/" } else { s3Prefix = "/sto-s3/" } a := args{ "aws_access_key": accessKey, "aws_secret_access_key": secret, "bucket": bucket, } if hostname != "" { a["hostname"] = hostname } b.addPrefix(s3Prefix, "storage-s3", a) if isPrimary { // TODO(mpl): s3CacheBucket // See http://code.google.com/p/camlistore/issues/detail?id=85 b.addPrefix("/cache/", "storage-filesystem", args{ "path": filepath.Join(tempDir(), "camli-cache"), }) } else { if b.high.BlobPath == "" { panic("unexpected empty blobpath with sync-to-s3") } b.addPrefix("/sync-to-s3/", "sync", args{ "from": "/bs/", "to": s3Prefix, "queue": map[string]interface{}{ "type": "kv", "file": filepath.Join(b.high.BlobPath, "sync-to-s3-queue.kv"), }, }) } return nil } func (b *lowBuilder) addGoogleDriveConfig(v string) error { f := strings.SplitN(v, ":", 4) if len(f) != 4 { return errors.New(`genconfig: expected "googledrive" field to be of form "client_id:client_secret:refresh_token:parent_id"`) } clientId, secret, refreshToken, parentId := f[0], f[1], f[2], f[3] isPrimary := !b.hasPrefix("/bs/") prefix := "" if isPrimary { prefix = "/bs/" } else { prefix = "/sto-googledrive/" } b.addPrefix(prefix, "storage-googledrive", args{ "parent_id": parentId, "auth": map[string]interface{}{ "client_id": clientId, "client_secret": secret, "refresh_token": refreshToken, }, }) if isPrimary { b.addPrefix("/cache/", "storage-filesystem", args{ "path": filepath.Join(tempDir(), "camli-cache"), }) } else { b.addPrefix("/sync-to-googledrive/", "sync", args{ "from": "/bs/", "to": prefix, "queue": map[string]interface{}{ "type": "kv", "file": filepath.Join(b.high.BlobPath, "sync-to-googledrive-queue.kv"), }, }) } return nil } var errGCSUsage = errors.New(`genconfig: expected "googlecloudstorage" field to be of form "client_id:client_secret:refresh_token:bucket" or ":bucketname"`) func (b *lowBuilder) addGoogleCloudStorageConfig(v string) error { var clientID, secret, refreshToken, bucket string f := strings.SplitN(v, ":", 4) switch len(f) { default: return errGCSUsage case 4: clientID, secret, refreshToken, bucket = f[0], f[1], f[2], f[3] case 2: if f[0] != "" { return errGCSUsage } bucket = f[1] clientID = "auto" } isPrimary := !b.hasPrefix("/bs/") gsPrefix := "" if isPrimary { gsPrefix = "/bs/" } else { gsPrefix = "/sto-googlecloudstorage/" } b.addPrefix(gsPrefix, "storage-googlecloudstorage", args{ "bucket": bucket, "auth": map[string]interface{}{ "client_id": clientID, "client_secret": secret, "refresh_token": refreshToken, }, }) if isPrimary { // TODO: cacheBucket like s3CacheBucket? b.addPrefix("/cache/", "storage-filesystem", args{ "path": filepath.Join(tempDir(), "camli-cache"), }) } else { b.addPrefix("/sync-to-googlecloudstorage/", "sync", args{ "from": "/bs/", "to": gsPrefix, "queue": map[string]interface{}{ "type": "kv", "file": filepath.Join(b.high.BlobPath, "sync-to-googlecloud-queue.kv"), }, }) } return nil } // indexFileDir returns the directory of the sqlite or kv file, or the // empty string. func (b *lowBuilder) indexFileDir() string { switch { case b.high.SQLite != "": return filepath.Dir(b.high.SQLite) case b.high.KVFile != "": return filepath.Dir(b.high.KVFile) } return "" } func (b *lowBuilder) syncToIndexArgs() (map[string]interface{}, error) { a := map[string]interface{}{ "from": "/bs/", "to": "/index/", } const sortedType = "queue-sync-to-index" if dbName := b.dbName(sortedType); dbName != "" { qj, err := b.sortedStorage(sortedType) if err != nil { return nil, err } a["queue"] = qj return a, nil } // TODO: currently when using s3, the index must be // sqlite or kvfile, since only through one of those // can we get a directory. if b.high.BlobPath == "" && b.indexFileDir() == "" { // We don't actually have a working sync handler, but we keep a stub registered // so it can be referred to from other places. // See http://camlistore.org/issue/201 a["idle"] = true return a, nil } dir := b.high.BlobPath if dir == "" { dir = b.indexFileDir() } typ := "kv" if b.high.SQLite != "" { typ = "sqlite" } a["queue"] = map[string]interface{}{ "type": typ, "file": filepath.Join(dir, "sync-to-index-queue."+typ), } return a, nil } func (b *lowBuilder) genLowLevelPrefixes() error { root := "/bs/" pubKeyDest := root if b.runIndex() { root = "/bs-and-maybe-also-index/" pubKeyDest = "/bs-and-index/" } rootArgs := map[string]interface{}{ "stealth": false, "blobRoot": root, "statusRoot": "/status/", } if b.high.OwnerName != "" { rootArgs["ownerName"] = b.high.OwnerName } if b.runIndex() { rootArgs["searchRoot"] = "/my-search/" } b.addPrefix("/", "root", rootArgs) b.addPrefix("/setup/", "setup", nil) b.addPrefix("/status/", "status", nil) importerArgs := args{} if b.high.Flickr != "" { importerArgs["flickr"] = map[string]interface{}{ "clientSecret": b.high.Flickr, } } if b.high.Picasa != "" { importerArgs["picasa"] = map[string]interface{}{ "clientSecret": b.high.Picasa, } } if b.runIndex() { b.addPrefix("/importer/", "importer", importerArgs) } if path := b.high.ShareHandlerPath; path != "" { b.addPrefix(path, "share", args{ "blobRoot": "/bs/", }) } b.addPrefix("/sighelper/", "jsonsign", args{ "secretRing": b.high.IdentitySecretRing, "keyId": b.high.Identity, "publicKeyDest": pubKeyDest, }) storageType := "filesystem" if b.high.PackBlobs { storageType = "diskpacked" } if b.high.BlobPath != "" { b.addPrefix("/bs/", "storage-"+storageType, args{ "path": b.high.BlobPath, }) b.addPrefix("/cache/", "storage-"+storageType, args{ "path": filepath.Join(b.high.BlobPath, "/cache"), }) } if b.runIndex() { syncArgs, err := b.syncToIndexArgs() if err != nil { return err } b.addPrefix("/sync/", "sync", syncArgs) b.addPrefix("/bs-and-index/", "storage-replica", args{ "backends": []interface{}{"/bs/", "/index/"}, }) b.addPrefix("/bs-and-maybe-also-index/", "storage-cond", args{ "write": map[string]interface{}{ "if": "isSchema", "then": "/bs-and-index/", "else": "/bs/", }, "read": "/bs/", }) owner, err := b.searchOwner() if err != nil { return err } searchArgs := args{ "index": "/index/", "owner": owner.String(), } if b.memoryIndex() { searchArgs["slurpToMemory"] = true } b.addPrefix("/my-search/", "search", searchArgs) } return nil } func (b *lowBuilder) build() (*Config, error) { conf, low := b.high, b.low 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)") } if conf.HTTPSCert != "" { low["httpsCert"] = conf.HTTPSCert low["httpsKey"] = conf.HTTPSKey } else { low["httpsCert"] = osutil.DefaultTLSCert() low["httpsKey"] = osutil.DefaultTLSKey() } } if conf.BaseURL != "" { u, err := url.Parse(conf.BaseURL) if err != nil { return nil, fmt.Errorf("Error parsing baseURL %q as a URL: %v", conf.BaseURL, err) } if u.Path != "" && u.Path != "/" { return nil, fmt.Errorf("baseURL can't have a path, only a scheme, host, and optional port.") } u.Path = "" low["baseURL"] = u.String() } if conf.Listen != "" { low["listen"] = conf.Listen } low["https"] = conf.HTTPS low["auth"] = conf.Auth numIndexers := numSet(conf.Mongo, conf.MySQL, conf.PostgreSQL, conf.SQLite, conf.KVFile) switch { case b.runIndex() && numIndexers == 0: return nil, fmt.Errorf("Unless runIndex is set to false, you must specify an index option (kvIndexFile, mongo, mysql, postgres, sqlite).") case b.runIndex() && numIndexers != 1: return nil, fmt.Errorf("With runIndex set true, you can only pick exactly one indexer (mongo, mysql, postgres, sqlite).") case !b.runIndex() && numIndexers != 0: return nil, fmt.Errorf("With runIndex disabled, you can't specify any of mongo, mysql, postgres, sqlite.") } if conf.Identity == "" { return nil, errors.New("no 'identity' in server config") } nolocaldisk := conf.BlobPath == "" if nolocaldisk { if conf.S3 == "" && conf.GoogleCloudStorage == "" { return nil, errors.New("You need at least one of blobPath (for localdisk) or s3 or googlecloudstorage configured for a blobserver.") } if conf.S3 != "" && conf.GoogleCloudStorage != "" { return nil, errors.New("Using S3 as a primary storage and Google Cloud Storage as a mirror is not supported for now.") } } if conf.ShareHandler && conf.ShareHandlerPath == "" { conf.ShareHandlerPath = "/share/" } if err := b.genLowLevelPrefixes(); err != nil { return nil, err } var cacheDir string if nolocaldisk { // Whether camlistored is run from EC2 or not, we use // a temp dir as the cache when primary storage is S3. // TODO(mpl): s3CacheBucket // See http://code.google.com/p/camlistore/issues/detail?id=85 cacheDir = filepath.Join(tempDir(), "camli-cache") } else { cacheDir = filepath.Join(conf.BlobPath, "cache") } if !noMkdir { if err := os.MkdirAll(cacheDir, 0700); err != nil { return nil, fmt.Errorf("Could not create blobs cache dir %s: %v", cacheDir, err) } } if len(conf.Publish) > 0 { if !b.runIndex() { return nil, fmt.Errorf("publishing requires an index") } var tlsO *tlsOpts httpsCert, ok1 := low["httpsCert"].(string) httpsKey, ok2 := low["httpsKey"].(string) if ok1 && ok2 { tlsO = &tlsOpts{ httpsCert: httpsCert, httpsKey: httpsKey, } } if err := b.addPublishedConfig(tlsO); err != nil { return nil, fmt.Errorf("Could not generate config for published: %v", err) } } if b.runIndex() { b.addUIConfig() sto, err := b.sortedStorage("index") if err != nil { return nil, err } b.addPrefix("/index/", "storage-index", args{ "blobSource": "/bs/", "storage": sto, }) } if conf.S3 != "" { if err := b.addS3Config(conf.S3); err != nil { return nil, err } } if conf.GoogleDrive != "" { if err := b.addGoogleDriveConfig(conf.GoogleDrive); err != nil { return nil, err } } if conf.GoogleCloudStorage != "" { if err := b.addGoogleCloudStorageConfig(conf.GoogleCloudStorage); err != nil { return nil, err } } return &Config{Obj: b.low}, nil } func numSet(vv ...interface{}) (num int) { for _, vi := range vv { switch v := vi.(type) { case string: if v != "" { num++ } case bool: if v { num++ } default: panic("unknown type") } } return } // WriteDefaultConfigFile generates a new default high-level server configuration // file at filePath. If useSQLite, the default indexer will use SQLite, otherwise // kv. If filePath already exists, it is overwritten. func WriteDefaultConfigFile(filePath string, useSQLite bool) error { conf := serverconfig.Config{ Listen: ":3179", HTTPS: false, Auth: "localhost", ReplicateTo: make([]interface{}, 0), } blobDir := osutil.CamliBlobRoot() if err := wkfs.MkdirAll(blobDir, 0700); err != nil { return fmt.Errorf("Could not create default blobs directory: %v", err) } conf.BlobPath = blobDir if useSQLite { conf.SQLite = filepath.Join(osutil.CamliVarDir(), "camli-index.db") } else { conf.KVFile = filepath.Join(osutil.CamliVarDir(), "camli-index.kvdb") } keyID, secretRing, err := getOrMakeKeyring() if err != nil { return err } conf.Identity = keyID conf.IdentitySecretRing = secretRing confData, err := json.MarshalIndent(conf, "", " ") if err != nil { return fmt.Errorf("Could not json encode config file : %v", err) } if err := wkfs.WriteFile(filePath, confData, 0600); err != nil { return fmt.Errorf("Could not create or write default server config: %v", err) } return nil } func getOrMakeKeyring() (keyID, secRing string, err error) { secRing = osutil.SecretRingFile() _, err = wkfs.Stat(secRing) switch { case err == nil: keyID, err = jsonsign.KeyIdFromRing(secRing) if err != nil { err = fmt.Errorf("Could not find any keyID in file %q: %v", secRing, err) return } log.Printf("Re-using identity with keyID %q found in file %s", keyID, secRing) case os.IsNotExist(err): keyID, err = jsonsign.GenerateNewSecRing(secRing) if err != nil { err = fmt.Errorf("Could not generate new secRing at file %q: %v", secRing, err) return } log.Printf("Generated new identity with keyID %q in file %s", keyID, secRing) default: err = fmt.Errorf("Could not stat secret ring %q: %v", secRing, err) } return }