diff --git a/app/hello/main.go b/app/hello/main.go new file mode 100644 index 000000000..f7ede0ea0 --- /dev/null +++ b/app/hello/main.go @@ -0,0 +1,97 @@ +/* +Copyright 2014 The Camlistore Authors. + +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. +*/ + +// The hello application serves as an example on how to make stand-alone +// server applications, interacting with a Camlistore server. +package main + +import ( + "flag" + "fmt" + "log" + "net/http" + "os" + "runtime" + + "camlistore.org/pkg/app" + "camlistore.org/pkg/buildinfo" + "camlistore.org/pkg/webserver" +) + +var ( + flagVersion = flag.Bool("version", false, "show version") +) + +// config is used to unmarshal the application configuration JSON +// that we get from Camlistore when we request it at $CAMLI_APP_CONFIG_URL. +type config struct { + Word string `json:"word,omitempty"` // Argument printed after "Hello " in the helloHandler response. +} + +func appConfig() *config { + configURL := os.Getenv("CAMLI_APP_CONFIG_URL") + if configURL == "" { + log.Fatalf("Hello application needs a CAMLI_APP_CONFIG_URL env var") + } + cl, err := app.Client() + if err != nil { + log.Fatalf("could not get a client to fetch extra config: %v", err) + } + conf := &config{} + if err := cl.GetJSON(configURL, conf); err != nil { + log.Fatalf("could not get app config at %v: %v", configURL, err) + } + return conf +} + +type helloHandler struct { + who string // who to say hello to. +} + +func (h *helloHandler) ServeHTTP(rw http.ResponseWriter, req *http.Request) { + rw.Header().Set("Content-Type", "text/plain; charset=utf-8") + rw.WriteHeader(200) + fmt.Fprintf(rw, "Hello %s\n", h.who) +} + +func main() { + flag.Parse() + + if *flagVersion { + fmt.Fprintf(os.Stderr, "hello version: %s\nGo version: %s (%s/%s)\n", + buildinfo.Version(), runtime.Version(), runtime.GOOS, runtime.GOARCH) + return + } + + log.Printf("Starting hello version %s; Go %s (%s/%s)", buildinfo.Version(), runtime.Version(), + runtime.GOOS, runtime.GOARCH) + + listenAddr, err := app.ListenAddress() + if err != nil { + log.Fatalf("Listen address: %v", err) + } + conf := appConfig() + ws := webserver.New() + ws.Handle("/", &helloHandler{who: conf.Word}) + // TODO(mpl): handle status requests too. Camlistore will send an auth + // token in the extra config that should be used as the "password" for + // subsequent status requests. + if err := ws.Listen(listenAddr); err != nil { + log.Fatalf("Listen: %v", err) + } + + ws.Serve() +} diff --git a/config/dev-server-config.json b/config/dev-server-config.json index 6204c28dd..d6be71099 100644 --- a/config/dev-server-config.json +++ b/config/dev-server-config.json @@ -17,6 +17,17 @@ } }, + "/hello/": { + "handler": "app", + "handlerArgs": { + "program": "hello", + "baseURL": "http://localhost:3178/", + "appConfig": { + "word": "world" + } + } + }, + "/blog/": { "enabled": ["_env", "${CAMLI_PUBLISH_ENABLED}"], "handler": "publish", diff --git a/doc/app-environment.text b/doc/app-environment.text new file mode 100644 index 000000000..a9e07e772 --- /dev/null +++ b/doc/app-environment.text @@ -0,0 +1,24 @@ +Camlistore applications run with the following environment variables set: + +CAMLI_APP_BASEURL (string): + URL prefix of the application's root, always ending in a trailing slash. Examples: + https://foo.org:3178/pub/ + https://foo.org/pub/ + http://192.168.0.1/ + http://192.168.0.1:1234/ + +CAMLI_APP_CONFIG_URL (string): + URL containing JSON configuration for the app. The body of this URL comes from the + "appConfig" part of the config file. + +CAMLI_AUTH (string): + Username and password (username:password) that the app should use to authenticate + over HTTP basic auth with the Camlistore server. Basic auth is unencrypted, hence + it should only be used with HTTPS or in a secure (local loopback) environment. + +CAMLI_SERVER (string): + URL prefix of Camlistore's root, always ending in a trailing slash. Examples: + https://foo.org:3178/pub/ + https://foo.org/pub/ + http://192.168.0.1/ + http://192.168.0.1:1234/ diff --git a/make.go b/make.go index c588bd80a..b4c472ac9 100644 --- a/make.go +++ b/make.go @@ -133,7 +133,7 @@ func main() { // TODO(mpl): main is getting long. We could probably move all the mirroring // dance to its own func. // We copy all *.go files from camRoot's goDirs to buildSrcDir. - goDirs := []string{"cmd", "pkg", "dev", "server/camlistored", "third_party"} + goDirs := []string{"app", "cmd", "pkg", "dev", "server/camlistored", "third_party"} if *onlysync { goDirs = append(goDirs, "server/appengine", "config") } @@ -168,6 +168,7 @@ func main() { "camlistore.org/cmd/camput", "camlistore.org/cmd/camtool", "camlistore.org/server/camlistored", + "camlistore.org/app/hello", } switch *targets { case "*": @@ -227,6 +228,7 @@ func main() { if buildAll { args = append(args, + "camlistore.org/app/...", "camlistore.org/pkg/...", "camlistore.org/server/...", "camlistore.org/third_party/...", diff --git a/pkg/app/app.go b/pkg/app/app.go new file mode 100644 index 000000000..3baa58908 --- /dev/null +++ b/pkg/app/app.go @@ -0,0 +1,75 @@ +/* +Copyright 2014 The Camlistore Authors. + +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 app provides helpers for server applications interacting +// with Camlistore. +package app + +import ( + "errors" + "fmt" + "net/http" + "os" + "strings" + + "camlistore.org/pkg/auth" + "camlistore.org/pkg/client" +) + +// Client returns a client from pkg/client, configured by environment variables +// for applications, and ready to be used to connect to the Camlistore server. +func Client() (*client.Client, error) { + server := os.Getenv("CAMLI_SERVER") + if server == "" { + return nil, errors.New("CAMLI_SERVER var not set") + } + authString := os.Getenv("CAMLI_AUTH") + if authString == "" { + return nil, errors.New("CAMLI_AUTH var not set") + } + userpass := strings.Split(authString, ":") + if len(userpass) != 2 { + return nil, fmt.Errorf("invalid auth string syntax. got %q, want \"username:password\"", authString) + } + cl := client.NewFromParams(server, auth.NewBasicAuth(userpass[0], userpass[1])) + cl.SetHTTPClient(&http.Client{ + Transport: cl.TransportForConfig(nil), + }) + return cl, nil +} + +// ListenAddress returns the host:[port] network address, derived from the environment, +// that the application should listen on. +func ListenAddress() (string, error) { + baseURL := os.Getenv("CAMLI_APP_BASEURL") + if baseURL == "" { + return "", errors.New("CAMLI_APP_BASEURL is undefined") + } + defaultPort := "80" + noScheme := strings.TrimPrefix(baseURL, "http://") + if strings.HasPrefix(baseURL, "https://") { + noScheme = strings.TrimPrefix(baseURL, "https://") + defaultPort = "443" + } + hostPortPrefix := strings.SplitN(noScheme, "/", 2) + if len(hostPortPrefix) != 2 { + return "", fmt.Errorf("invalid CAMLI_APP_BASEURL: %q (no trailing slash?)", baseURL) + } + if !strings.Contains(hostPortPrefix[0], ":") { + return fmt.Sprintf("%s:%s", hostPortPrefix[0], defaultPort), nil + } + return hostPortPrefix[0], nil +} diff --git a/pkg/app/app_test.go b/pkg/app/app_test.go new file mode 100644 index 000000000..493bcf771 --- /dev/null +++ b/pkg/app/app_test.go @@ -0,0 +1,117 @@ +/* +Copyright 2014 The Camlistore Authors + +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 app + +import ( + "os" + "testing" +) + +func TestListenAddress(t *testing.T) { + tests := []struct { + baseURL string + wantAddr string + wantErr bool + }{ + { + baseURL: "http://foo.com/", + wantAddr: "foo.com:80", + }, + + { + baseURL: "https://foo.com/", + wantAddr: "foo.com:443", + }, + + { + baseURL: "http://foo.com:8080/", + wantAddr: "foo.com:8080", + }, + + { + baseURL: "https://foo.com:8080/", + wantAddr: "foo.com:8080", + }, + + { + baseURL: "http://foo.com:/", + wantAddr: "foo.com:", + }, + + { + baseURL: "https://foo.com:/", + wantAddr: "foo.com:", + }, + + { + baseURL: "http://foo.com/bar/", + wantAddr: "foo.com:80", + }, + + { + baseURL: "https://foo.com/bar/", + wantAddr: "foo.com:443", + }, + + { + baseURL: "http://foo.com:8080/bar/", + wantAddr: "foo.com:8080", + }, + + { + baseURL: "https://foo.com:8080/bar/", + wantAddr: "foo.com:8080", + }, + + { + baseURL: "http://foo.com:/bar/", + wantAddr: "foo.com:", + }, + + { + baseURL: "https://foo.com:/bar/", + wantAddr: "foo.com:", + }, + + { + baseURL: "", + wantErr: true, + }, + + { + baseURL: "http://foo.com", + wantErr: true, + }, + } + for _, v := range tests { + os.Setenv("CAMLI_APP_BASEURL", v.baseURL) + got, err := ListenAddress() + if v.wantErr { + if err == nil { + t.Errorf("Wanted error for %v", v.baseURL) + } + continue + } + if err != nil { + t.Error(err) + continue + } + if got != v.wantAddr { + t.Errorf("got: %v, want: %v", got, v.wantAddr) + } + } +} diff --git a/pkg/auth/auth.go b/pkg/auth/auth.go index 3290796aa..bd84d3391 100644 --- a/pkg/auth/auth.go +++ b/pkg/auth/auth.go @@ -82,6 +82,7 @@ var authConstructor = map[string]AuthConfigParser{ "localhost": newLocalhostAuth, "userpass": newUserPassAuth, "devauth": newDevAuth, + "basic": newBasicAuth, } // RegisterAuth registers a new authentication scheme. @@ -111,7 +112,7 @@ func newDevAuth(pw string) (AuthMode, error) { func newUserPassAuth(arg string) (AuthMode, error) { pieces := strings.Split(arg, ":") if len(pieces) < 2 { - return nil, fmt.Errorf("Wrong userpass auth string; needs to be \"userpass:user:password\"") + return nil, fmt.Errorf("Wrong userpass auth string; needs to be \"user:password\"") } username := pieces[0] password := pieces[1] @@ -130,6 +131,23 @@ func newUserPassAuth(arg string) (AuthMode, error) { return mode, nil } +func newBasicAuth(arg string) (AuthMode, error) { + pieces := strings.Split(arg, ":") + if len(pieces) != 2 { + return nil, fmt.Errorf("invalid basic auth syntax. got %q, want \"username:password\"", arg) + } + return NewBasicAuth(pieces[0], pieces[1]), nil +} + +// NewBasicAuth returns a UserPass Authmode, adequate to support HTTP +// basic authentication. +func NewBasicAuth(username, password string) AuthMode { + return &UserPass{ + Username: username, + Password: password, + } +} + // ErrNoAuth is returned when there is no configured authentication. var ErrNoAuth = errors.New("auth: no configured authentication") @@ -347,9 +365,15 @@ func ProcessRandom() string { } func genProcessRand() { - buf := make([]byte, 20) + processRand = RandToken(20) +} + +// RandToken genererates (with crypto/rand.Read) and returns a token +// that is the hex version (2x size) of size bytes of randomness. +func RandToken(size int) string { + buf := make([]byte, size) if n, err := rand.Read(buf); err != nil || n != len(buf) { panic("failed to get random: " + err.Error()) } - processRand = fmt.Sprintf("%x", buf) + return fmt.Sprintf("%x", buf) } diff --git a/pkg/auth/auth_test.go b/pkg/auth/auth_test.go index 994b8f1a8..af6c60530 100644 --- a/pkg/auth/auth_test.go +++ b/pkg/auth/auth_test.go @@ -37,6 +37,9 @@ func TestFromConfig(t *testing.T) { {in: "userpass:alice:secret:+localhost", want: &UserPass{Username: "alice", Password: "secret", OrLocalhost: true, VivifyPass: ""}}, {in: "userpass:alice:secret:+localhost:vivify=foo", want: &UserPass{Username: "alice", Password: "secret", OrLocalhost: true, VivifyPass: "foo"}}, {in: "devauth:port3179", want: &DevAuth{Password: "port3179", VivifyPass: "viviport3179"}}, + {in: "basic:alice:secret", want: &Basic{Username: "alice", Password: "secret", OrLocalhost: true, VivifyPass: ""}}, + {in: "basic:alice:secret:+localhost", wanterr: `invalid basic auth syntax. got "alice:secret:+localhost", want "username:password"`}, + {in: "basic:alice:secret:+vivify=foo", wanterr: `invalid basic auth syntax. got "alice:secret:+vivify=foo", want "username:password"`}, } for _, tt := range tests { am, err := FromConfig(tt.in) diff --git a/pkg/client/client.go b/pkg/client/client.go index 0d43dddb0..49b8bb5d0 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -770,6 +770,21 @@ func (c *Client) doDiscovery() error { return nil } +// GetJSON sends a GET request to url, and unmarshals the returned +// JSON response into data. The URL's host must match the client's +// configured server. +func (c *Client) GetJSON(url string, data interface{}) error { + if !strings.HasPrefix(url, c.discoRoot()) { + return fmt.Errorf("wrong URL (%q) for this server", url) + } + hreq := c.newRequest("GET", url) + resp, err := c.expect2XX(hreq) + if err != nil { + return err + } + return httputil.DecodeJSON(resp, data) +} + func (c *Client) newRequest(method, url string, body ...io.Reader) *http.Request { var bodyR io.Reader if len(body) > 0 { diff --git a/pkg/server/app/app.go b/pkg/server/app/app.go new file mode 100644 index 000000000..401099cbd --- /dev/null +++ b/pkg/server/app/app.go @@ -0,0 +1,164 @@ +/* +Copyright 2014 The Camlistore Authors. + +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 app helps with configuring and starting server applications +// from Camlistore. +package app + +import ( + "fmt" + "log" + "net/http" + "net/http/httputil" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" + + "camlistore.org/pkg/auth" + camhttputil "camlistore.org/pkg/httputil" + "camlistore.org/pkg/jsonconfig" + "camlistore.org/pkg/osutil" +) + +// AppHandler acts as a reverse proxy for a server application started by +// Camlistore. It can also serve some extra JSON configuration to the app. +type AppHandler struct { + name string // Name of the app's program. + envVars map[string]string // Variables set in the app's process environment. See pkg/app/vars.txt. + + auth auth.AuthMode // Used for basic HTTP authenticating against the app requests. + appConfig jsonconfig.Obj // Additional parameters the app can request, or nil. + + proxy *httputil.ReverseProxy // For redirecting requests to the app. +} + +func (a *AppHandler) ServeHTTP(rw http.ResponseWriter, req *http.Request) { + if camhttputil.PathSuffix(req) == "config.json" { + if a.auth.AllowedAccess(req)&auth.OpGet == auth.OpGet { + camhttputil.ReturnJSON(rw, a.appConfig) + } else { + auth.SendUnauthorized(rw, req) + } + return + } + if a.proxy == nil { + http.Error(rw, "no proxy for the app", 500) + return + } + a.proxy.ServeHTTP(rw, req) +} + +// New returns a configured AppHandler that Camlistore can use during server initialization +// as a handler that proxies request to an app. It is also used to start the app. +// The conf object has the following members, related to the vars described in doc/app-environment.text: +// "program", string, required. Name of the app's program. +// "baseURL", string, required. See CAMLI_APP_BASEURL. +// "server", string, optional, overrides the camliBaseURL argument. See CAMLI_SERVER. +// "appConfig", object, optional. Additional configuration that the app can request from Camlistore. +func New(conf jsonconfig.Obj, serverBaseURL string) (*AppHandler, error) { + name := conf.RequiredString("program") + server := conf.OptionalString("server", serverBaseURL) + if server == "" { + return nil, fmt.Errorf("could not initialize AppHandler for %q: Camlistore baseURL is unknown", name) + } + baseURL := conf.RequiredString("baseURL") + appConfig := conf.OptionalObject("appConfig") + // TODO(mpl): add an auth token in the extra config of the dev server config, + // that the hello app can use to setup a status handler than only responds + // to requests with that token. + if err := conf.Validate(); err != nil { + return nil, err + } + + username, password := auth.RandToken(20), auth.RandToken(20) + camliAuth := username + ":" + password + basicAuth := auth.NewBasicAuth(username, password) + envVars := map[string]string{ + "CAMLI_SERVER": server, + "CAMLI_AUTH": camliAuth, + "CAMLI_APP_BASEURL": baseURL, + } + if appConfig != nil { + appConfigURL := fmt.Sprintf("%s/%s/%s", strings.TrimSuffix(server, "/"), name, "config.json") + envVars["CAMLI_APP_CONFIG_URL"] = appConfigURL + } + + proxyURL, err := url.Parse(baseURL) + if err != nil { + return nil, fmt.Errorf("could not parse baseURL %q: %v", baseURL, err) + } + return &AppHandler{ + name: name, + envVars: envVars, + auth: basicAuth, + appConfig: appConfig, + proxy: httputil.NewSingleHostReverseProxy(proxyURL), + }, nil +} + +func (a *AppHandler) Start() error { + name := a.name + if name == "" { + return fmt.Errorf("invalid app name: %q", name) + } + // first look for it in PATH + binPath, err := exec.LookPath(name) + if err != nil { + log.Printf("%q binary not found in PATH. now trying in the camlistore tree.", name) + // else try in the camlistore tree + binDir, err := osutil.GoPackagePath("camlistore.org/bin") + if err != nil { + return fmt.Errorf("bin dir in camlistore tree was not found: %v", err) + } + binPath = filepath.Join(binDir, name) + if _, err = os.Stat(binPath); err != nil { + return fmt.Errorf("could not find %v binary at %v: %v", name, binPath, err) + } + } + + cmd := exec.Command(binPath) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + // TODO(mpl): extract Env methods from dev/devcam/env.go to a util pkg and use them here. + newVars := make(map[string]string, len(a.envVars)) + for k, v := range a.envVars { + newVars[k+"="] = v + } + env := os.Environ() + for pos, oldkv := range env { + for k, newVal := range newVars { + if strings.HasPrefix(oldkv, k) { + env[pos] = k + newVal + delete(newVars, k) + break + } + } + } + for k, v := range newVars { + env = append(env, k+v) + } + cmd.Env = env + if err := cmd.Start(); err != nil { + return fmt.Errorf("could not start app %v: %v", name, err) + } + return nil +} + +func (a *AppHandler) Name() string { + return a.name +} diff --git a/pkg/serverinit/serverinit.go b/pkg/serverinit/serverinit.go index 4e32c47a9..121755a1a 100644 --- a/pkg/serverinit/serverinit.go +++ b/pkg/serverinit/serverinit.go @@ -42,6 +42,7 @@ import ( "camlistore.org/pkg/httputil" "camlistore.org/pkg/index" "camlistore.org/pkg/jsonconfig" + "camlistore.org/pkg/server/app" "camlistore.org/pkg/types/serverconfig" ) @@ -333,10 +334,20 @@ func (hl *handlerLoader) setupHandler(prefix string) { return } - hh, err := blobserver.CreateHandler(h.htype, hl, h.conf) - if err != nil { - exitFailure("error instantiating handler for prefix %q, type %q: %v", - h.prefix, h.htype, err) + var hh http.Handler + if h.htype == "app" { + ap, err := app.New(h.conf, hl.baseURL+"/") + if err != nil { + exitFailure("error setting up app for prefix %q: %v", h.prefix, err) + } + hh = ap + } else { + var err error + hh, err = blobserver.CreateHandler(h.htype, hl, h.conf) + if err != nil { + exitFailure("error instantiating handler for prefix %q, type %q: %v", + h.prefix, h.htype, err) + } } hl.handler[prefix] = hh @@ -345,7 +356,7 @@ func (hl *handlerLoader) setupHandler(prefix string) { wrappedHandler = unauthorizedHandler{} } else { wrappedHandler = &httputil.PrefixHandler{prefix, hh} - if handerTypeWantsAuth(h.htype) { + if handlerTypeWantsAuth(h.htype) { wrappedHandler = auth.Handler{wrappedHandler} } } @@ -358,7 +369,7 @@ func (unauthorizedHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { http.Error(w, "Unauthorized", http.StatusUnauthorized) } -func handerTypeWantsAuth(handlerType string) bool { +func handlerTypeWantsAuth(handlerType string) bool { // TODO(bradfitz): ask the handler instead? This is a bit of a // weird spot for this policy maybe? switch handlerType { @@ -375,6 +386,10 @@ type Config struct { jsonconfig.Obj UIPath string // Not valid until after InstallHandlers configPath string // Filesystem path + + // apps is the list of server apps configured during InstallHandlers, + // and that should be started after camlistored has started serving. + apps []*app.AppHandler } // detectConfigChange returns an informative error if conf contains obsolete keys. @@ -527,7 +542,11 @@ func (config *Config) InstallHandlers(hi HandlerInstaller, baseURL string, reind // Now that everything is setup, run any handlers' InitHandler // methods. + // And register apps that will be started later. for pfx, handler := range hl.handler { + if starter, ok := handler.(*app.AppHandler); ok { + config.apps = append(config.apps, starter) + } if in, ok := handler.(blobserver.HandlerIniter); ok { if err := in.InitHandler(hl); err != nil { return nil, fmt.Errorf("Error calling InitHandler on %s: %v", pfx, err) @@ -544,6 +563,19 @@ func (config *Config) InstallHandlers(hi HandlerInstaller, baseURL string, reind return multiCloser(hl.closers), nil } +// StartApps starts all the server applications that were configured +// during InstallHandlers. It should only be called after camlistored +// has started serving, since these apps might request some configuration +// from Camlistore to finish initializing. +func (config *Config) StartApps() error { + for _, ap := range config.apps { + if err := ap.Start(); err != nil { + return fmt.Errorf("error starting app %v: %v", ap.Name(), err) + } + } + return nil +} + func mustCreate(path string) *os.File { f, err := os.Create(path) if err != nil { diff --git a/server/camlistored/camlistored.go b/server/camlistored/camlistored.go index 1681c13f6..329a38ef0 100644 --- a/server/camlistored/camlistored.go +++ b/server/camlistored/camlistored.go @@ -395,6 +395,10 @@ func Main(up chan<- struct{}, down <-chan struct{}) { osutil.DieOnParentDeath() } + if err := config.StartApps(); err != nil { + exitf("StartApps: %v", err) + } + // Block forever, except during tests. up <- struct{}{} <-down