// Package common implements all the things that an IPFS Cluster API component // must do, except the actual routes that it handles. // // This is meant for re-use when implementing actual REST APIs by saving most // of the efforts and automatically getting a lot of the setup and things like // authentication handled. // // The API exposes the routes in two ways: the first is through a regular // HTTP(s) listener. The second is by tunneling HTTP through a libp2p stream // (thus getting an encrypted channel without the need to setup TLS). Both // ways can be used at the same time, or disabled. // // This is used by rest and pinsvc packages. package common import ( "context" "crypto/tls" "encoding/json" "errors" "fmt" "math/rand" "net" "net/http" "net/url" "strings" "sync" "time" jwt "github.com/golang-jwt/jwt/v4" types "github.com/ipfs-cluster/ipfs-cluster/api" state "github.com/ipfs-cluster/ipfs-cluster/state" logging "github.com/ipfs/go-log/v2" gopath "github.com/ipfs/go-path" libp2p "github.com/libp2p/go-libp2p" host "github.com/libp2p/go-libp2p/core/host" peer "github.com/libp2p/go-libp2p/core/peer" rpc "github.com/libp2p/go-libp2p-gorpc" gostream "github.com/libp2p/go-libp2p-gostream" p2phttp "github.com/libp2p/go-libp2p-http" noise "github.com/libp2p/go-libp2p/p2p/security/noise" libp2ptls "github.com/libp2p/go-libp2p/p2p/security/tls" manet "github.com/multiformats/go-multiaddr/net" handlers "github.com/gorilla/handlers" mux "github.com/gorilla/mux" "github.com/rs/cors" "go.opencensus.io/plugin/ochttp" "go.opencensus.io/plugin/ochttp/propagation/tracecontext" "go.opencensus.io/trace" ) func init() { rand.Seed(time.Now().UnixNano()) } // StreamChannelSize is used to define buffer sizes for channels. const StreamChannelSize = 1024 // Common errors var ( // ErrNoEndpointEnabled is returned when the API is created but // no HTTPListenAddr, nor libp2p configuration fields, nor a libp2p // Host are provided. ErrNoEndpointsEnabled = errors.New("neither the libp2p nor the HTTP endpoints are enabled") // ErrHTTPEndpointNotEnabled is returned when trying to perform // operations that rely on the HTTPEndpoint but it is disabled. ErrHTTPEndpointNotEnabled = errors.New("the HTTP endpoint is not enabled") ) // SetStatusAutomatically can be passed to SendResponse(), so that it will // figure out which http status to set by itself. const SetStatusAutomatically = -1 // API implements an API and aims to provides // a RESTful HTTP API for Cluster. type API struct { ctx context.Context cancel func() config *Config rpcClient *rpc.Client rpcReady chan struct{} router *mux.Router routes func(*rpc.Client) []Route server *http.Server host host.Host httpListeners []net.Listener libp2pListener net.Listener shutdownLock sync.Mutex shutdown bool wg sync.WaitGroup } // Route defines a REST endpoint supported by this API. type Route struct { Name string Method string Pattern string HandlerFunc http.HandlerFunc } type jwtToken struct { Token string `json:"token"` } type logWriter struct { logger *logging.ZapEventLogger } func (lw logWriter) Write(b []byte) (int, error) { lw.logger.Info(string(b)) return len(b), nil } // NewAPI creates a new common API component with the given configuration. func NewAPI(ctx context.Context, cfg *Config, routes func(*rpc.Client) []Route) (*API, error) { return NewAPIWithHost(ctx, cfg, nil, routes) } // NewAPIWithHost creates a new common API component and enables // the libp2p-http endpoint using the given Host, if not nil. func NewAPIWithHost(ctx context.Context, cfg *Config, h host.Host, routes func(*rpc.Client) []Route) (*API, error) { err := cfg.Validate() if err != nil { return nil, err } ctx, cancel := context.WithCancel(ctx) api := &API{ ctx: ctx, cancel: cancel, config: cfg, host: h, routes: routes, rpcReady: make(chan struct{}, 2), } // Our handler is a gorilla router wrapped with: // - a custom strictSlashHandler that uses 307 redirects (#1415) // - the cors handler, // - the basic auth handler. // // Requests will need to have valid credentials first, except // cors-preflight requests (OPTIONS). Then requests are handled by // CORS and potentially need to comply with it. Then they may be // redirected if the path ends with a "/". Finally they hit one of our // routes and handlers. router := mux.NewRouter() handler := api.authHandler( cors.New(*cfg.CorsOptions()). Handler( strictSlashHandler(router), ), cfg.Logger, ) if cfg.Tracing { handler = &ochttp.Handler{ IsPublicEndpoint: true, Propagation: &tracecontext.HTTPFormat{}, Handler: handler, StartOptions: trace.StartOptions{SpanKind: trace.SpanKindServer}, FormatSpanName: func(req *http.Request) string { return req.Host + ":" + req.URL.Path + ":" + req.Method }, } } writer, err := cfg.LogWriter() if err != nil { cancel() return nil, err } s := &http.Server{ ReadTimeout: cfg.ReadTimeout, ReadHeaderTimeout: cfg.ReadHeaderTimeout, WriteTimeout: cfg.WriteTimeout, IdleTimeout: cfg.IdleTimeout, Handler: handlers.LoggingHandler(writer, handler), MaxHeaderBytes: cfg.MaxHeaderBytes, } // See: https://github.com/ipfs/go-ipfs/issues/5168 // See: https://github.com/ipfs-cluster/ipfs-cluster/issues/548 // on why this is re-enabled. s.SetKeepAlivesEnabled(true) s.MaxHeaderBytes = cfg.MaxHeaderBytes api.server = s api.router = router // Set up api.httpListeners if enabled err = api.setupHTTP() if err != nil { return nil, err } // Set up api.libp2pListeners if enabled err = api.setupLibp2p() if err != nil { return nil, err } if len(api.httpListeners) == 0 && api.libp2pListener == nil { return nil, ErrNoEndpointsEnabled } api.run(ctx) return api, nil } func (api *API) setupHTTP() error { if len(api.config.HTTPListenAddr) == 0 { return nil } for _, listenMAddr := range api.config.HTTPListenAddr { n, addr, err := manet.DialArgs(listenMAddr) if err != nil { return err } var l net.Listener if api.config.TLS != nil { l, err = tls.Listen(n, addr, api.config.TLS) } else { l, err = net.Listen(n, addr) } if err != nil { return err } api.httpListeners = append(api.httpListeners, l) } return nil } func (api *API) setupLibp2p() error { // Make new host. Override any provided existing one // if we have config for a custom one. if len(api.config.Libp2pListenAddr) > 0 { // We use a new host context. We will call // Close() on shutdown(). Avoids things like: // https://github.com/ipfs-cluster/ipfs-cluster/issues/853 h, err := libp2p.New( libp2p.Identity(api.config.PrivateKey), libp2p.ListenAddrs(api.config.Libp2pListenAddr...), libp2p.Security(noise.ID, noise.New), libp2p.Security(libp2ptls.ID, libp2ptls.New), ) if err != nil { return err } api.host = h } if api.host == nil { return nil } l, err := gostream.Listen(api.host, p2phttp.DefaultP2PProtocol) if err != nil { return err } api.libp2pListener = l return nil } func (api *API) addRoutes() { for _, route := range api.routes(api.rpcClient) { api.router. Methods(route.Method). Path(route.Pattern). Name(route.Name). Handler( ochttp.WithRouteTag( http.HandlerFunc(route.HandlerFunc), "/"+route.Name, ), ) } api.router.NotFoundHandler = ochttp.WithRouteTag( http.HandlerFunc(api.notFoundHandler), "/notfound", ) } // authHandler takes care of authentication either using basicAuth or JWT bearer tokens. func (api *API) authHandler(h http.Handler, lggr *logging.ZapEventLogger) http.Handler { credentials := api.config.BasicAuthCredentials // If no credentials are set, we do nothing. if credentials == nil { return h } wrap := func(w http.ResponseWriter, r *http.Request) { // We let CORS preflight requests pass through the next // handler. if r.Method == http.MethodOptions { h.ServeHTTP(w, r) return } username, password, okBasic := r.BasicAuth() tokenString, okToken := parseBearerToken(r.Header.Get("Authorization")) switch { case okBasic: ok := verifyBasicAuth(credentials, username, password) if !ok { w.Header().Set("WWW-Authenticate", wwwAuthenticate("Basic", "Restricted IPFS Cluster API", "", "")) api.SendResponse(w, http.StatusUnauthorized, errors.New("unauthorized: access denied"), nil) return } case okToken: _, err := verifyToken(credentials, tokenString) if err != nil { lggr.Debug(err) w.Header().Set("WWW-Authenticate", wwwAuthenticate("Bearer", "Restricted IPFS Cluster API", "invalid_token", "")) api.SendResponse(w, http.StatusUnauthorized, errors.New("unauthorized: invalid token"), nil) return } default: // No authentication provided, but needed w.Header().Add("WWW-Authenticate", wwwAuthenticate("Bearer", "Restricted IPFS Cluster API", "", "")) w.Header().Add("WWW-Authenticate", wwwAuthenticate("Basic", "Restricted IPFS Cluster API", "", "")) api.SendResponse(w, http.StatusUnauthorized, errors.New("unauthorized: no auth provided"), nil) return } // If we are here, authentication worked. h.ServeHTTP(w, r) } return http.HandlerFunc(wrap) } func parseBearerToken(authHeader string) (string, bool) { const prefix = "Bearer " if len(authHeader) < len(prefix) || !strings.EqualFold(authHeader[:len(prefix)], prefix) { return "", false } return authHeader[len(prefix):], true } func wwwAuthenticate(auth, realm, error, description string) string { str := auth + ` realm="` + realm + `"` if len(error) > 0 { str += `, error="` + error + `"` } if len(description) > 0 { str += `, error_description="` + description + `"` } return str } func verifyBasicAuth(credentials map[string]string, username, password string) bool { if username == "" || password == "" { return false } for u, p := range credentials { if u == username && p == password { return true } } return false } // verify that a Bearer JWT token is valid. func verifyToken(credentials map[string]string, tokenString string) (*jwt.Token, error) { // The token should be signed with the basic auth credential password // of the issuer, and should have valid standard claims otherwise. token, err := jwt.ParseWithClaims(tokenString, &jwt.RegisteredClaims{}, func(token *jwt.Token) (interface{}, error) { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, errors.New("unexpected token signing method (not HMAC)") } if claims, ok := token.Claims.(*jwt.RegisteredClaims); ok { key, ok := credentials[claims.Issuer] if !ok { return nil, errors.New("issuer not found") } return []byte(key), nil } return nil, errors.New("no issuer set") }) if err != nil { return nil, err } if !token.Valid { return nil, errors.New("invalid token") } return token, nil } // The Gorilla muxer StrictSlash option uses a 301 permanent redirect, which // results in POST requests becoming GET requests in most clients. Thus we // use our own middleware that performs a 307 redirect. See issue #1415 for // more details. func strictSlashHandler(h http.Handler) http.Handler { wrap := func(w http.ResponseWriter, r *http.Request) { path := r.URL.Path if strings.HasSuffix(path, "/") { u, _ := url.Parse(r.URL.String()) u.Path = u.Path[:len(u.Path)-1] http.Redirect(w, r, u.String(), http.StatusTemporaryRedirect) return } h.ServeHTTP(w, r) } return http.HandlerFunc(wrap) } func (api *API) run(ctx context.Context) { api.wg.Add(len(api.httpListeners)) for _, l := range api.httpListeners { go func(l net.Listener) { defer api.wg.Done() api.runHTTPServer(ctx, l) }(l) } if api.libp2pListener != nil { api.wg.Add(1) go func() { defer api.wg.Done() api.runLibp2pServer(ctx) }() } } // runs in goroutine from run() func (api *API) runHTTPServer(ctx context.Context, l net.Listener) { select { case <-api.rpcReady: case <-api.ctx.Done(): return } maddr, err := manet.FromNetAddr(l.Addr()) if err != nil { api.config.Logger.Error(err) } var authInfo string if api.config.BasicAuthCredentials != nil { authInfo = " - authenticated" } api.config.Logger.Infof(strings.ToUpper(api.config.ConfigKey)+" (HTTP"+authInfo+"): %s", maddr) err = api.server.Serve(l) if err != nil && !strings.Contains(err.Error(), "closed network connection") { api.config.Logger.Error(err) } } // runs in goroutine from run() func (api *API) runLibp2pServer(ctx context.Context) { select { case <-api.rpcReady: case <-api.ctx.Done(): return } listenMsg := "" for _, a := range api.host.Addrs() { listenMsg += fmt.Sprintf(" %s/p2p/%s\n", a, api.host.ID().Pretty()) } api.config.Logger.Infof(strings.ToUpper(api.config.ConfigKey)+" (libp2p-http): ENABLED. Listening on:\n%s\n", listenMsg) err := api.server.Serve(api.libp2pListener) if err != nil && !strings.Contains(err.Error(), "context canceled") { api.config.Logger.Error(err) } } // Shutdown stops any API listeners. func (api *API) Shutdown(ctx context.Context) error { _, span := trace.StartSpan(ctx, "api/Shutdown") defer span.End() api.shutdownLock.Lock() defer api.shutdownLock.Unlock() if api.shutdown { api.config.Logger.Debug("already shutdown") return nil } api.config.Logger.Info("stopping Cluster API") api.cancel() close(api.rpcReady) // Cancel any outstanding ops api.server.SetKeepAlivesEnabled(false) for _, l := range api.httpListeners { l.Close() } if api.libp2pListener != nil { api.libp2pListener.Close() } api.wg.Wait() // This means we created the host if api.config.Libp2pListenAddr != nil { api.host.Close() } api.shutdown = true return nil } // SetClient makes the component ready to perform RPC // requests. func (api *API) SetClient(c *rpc.Client) { api.rpcClient = c api.addRoutes() // One notification for http server and one for libp2p server. api.rpcReady <- struct{}{} api.rpcReady <- struct{}{} } func (api *API) notFoundHandler(w http.ResponseWriter, r *http.Request) { api.SendResponse(w, http.StatusNotFound, errors.New("not found"), nil) } // Context returns the API context func (api *API) Context() context.Context { return api.ctx } // ParsePinPathOrFail parses a pin path and returns it or makes the request // fail. func (api *API) ParsePinPathOrFail(w http.ResponseWriter, r *http.Request) types.PinPath { vars := mux.Vars(r) urlpath := "/" + vars["keyType"] + "/" + strings.TrimSuffix(vars["path"], "/") path, err := gopath.ParsePath(urlpath) if err != nil { api.SendResponse(w, http.StatusBadRequest, errors.New("error parsing path: "+err.Error()), nil) return types.PinPath{} } pinPath := types.PinPath{Path: path.String()} err = pinPath.PinOptions.FromQuery(r.URL.Query()) if err != nil { api.SendResponse(w, http.StatusBadRequest, err, nil) } return pinPath } // ParseCidOrFail parses a Cid and returns it or makes the request fail. func (api *API) ParseCidOrFail(w http.ResponseWriter, r *http.Request) types.Pin { vars := mux.Vars(r) hash := vars["hash"] c, err := types.DecodeCid(hash) if err != nil { api.SendResponse(w, http.StatusBadRequest, errors.New("error decoding Cid: "+err.Error()), nil) return types.Pin{} } opts := types.PinOptions{} err = opts.FromQuery(r.URL.Query()) if err != nil { api.SendResponse(w, http.StatusBadRequest, err, nil) } pin := types.PinWithOpts(c, opts) pin.MaxDepth = -1 // For now, all pins are recursive return pin } // ParsePidOrFail parses a PID and returns it or makes the request fail. func (api *API) ParsePidOrFail(w http.ResponseWriter, r *http.Request) peer.ID { vars := mux.Vars(r) idStr := vars["peer"] pid, err := peer.Decode(idStr) if err != nil { api.SendResponse(w, http.StatusBadRequest, errors.New("error decoding Peer ID: "+err.Error()), nil) return "" } return pid } // GenerateTokenHandler is a handle to obtain a new JWT token func (api *API) GenerateTokenHandler(w http.ResponseWriter, r *http.Request) { if api.config.BasicAuthCredentials == nil { api.SendResponse(w, http.StatusUnauthorized, errors.New("unauthorized"), nil) return } var issuer string // We do not verify as we assume it is already done! user, _, okBasic := r.BasicAuth() tokenString, okToken := parseBearerToken(r.Header.Get("Authorization")) if okBasic { issuer = user } else if okToken { token, err := verifyToken(api.config.BasicAuthCredentials, tokenString) if err != nil { // I really hope not because it should be verified api.config.Logger.Error("verify token failed in GetTokenHandler!") api.SendResponse(w, http.StatusUnauthorized, errors.New("unauthorized"), nil) return } if claims, ok := token.Claims.(*jwt.RegisteredClaims); ok { issuer = claims.Issuer } else { api.SendResponse(w, http.StatusUnauthorized, errors.New("unauthorized"), nil) return } } else { // no issuer api.SendResponse(w, http.StatusUnauthorized, errors.New("unauthorized"), nil) return } pass, okPass := api.config.BasicAuthCredentials[issuer] if !okPass { // another place that should never be reached api.SendResponse(w, http.StatusUnauthorized, errors.New("unauthorized"), nil) return } ss, err := generateSignedTokenString(issuer, pass) if err != nil { api.SendResponse(w, SetStatusAutomatically, err, nil) return } tokenObj := jwtToken{Token: ss} api.SendResponse(w, SetStatusAutomatically, nil, tokenObj) } func generateSignedTokenString(issuer, pass string) (string, error) { key := []byte(pass) claims := jwt.RegisteredClaims{ Issuer: issuer, } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) return token.SignedString(key) } // SendResponse wraps all the logic for writing the response to a request: // * Write configured headers // * Write application/json content type // * Write status: determined automatically if given "SetStatusAutomatically" // * Write an error if there is or write the response if there is func (api *API) SendResponse( w http.ResponseWriter, status int, err error, resp interface{}, ) { api.SetHeaders(w) enc := json.NewEncoder(w) // Send an error if err != nil { if status == SetStatusAutomatically || status < 400 { if err.Error() == state.ErrNotFound.Error() { status = http.StatusNotFound } else { status = http.StatusInternalServerError } } w.WriteHeader(status) errorResp := api.config.APIErrorFunc(err, status) api.config.Logger.Errorf("sending error response: %d: %s", status, err.Error()) if err := enc.Encode(errorResp); err != nil { api.config.Logger.Error(err) } return } // Send a body if resp != nil { if status == SetStatusAutomatically { status = http.StatusOK } w.WriteHeader(status) if err = enc.Encode(resp); err != nil { api.config.Logger.Error(err) } return } // Empty response if status == SetStatusAutomatically { status = http.StatusNoContent } w.WriteHeader(status) } // StreamIterator is a function that returns the next item. It is used in // StreamResponse. type StreamIterator func() (interface{}, bool, error) // StreamResponse reads from an iterator and sends the response. func (api *API) StreamResponse(w http.ResponseWriter, next StreamIterator, errCh chan error) { api.SetHeaders(w) enc := json.NewEncoder(w) flusher, flush := w.(http.Flusher) w.Header().Set("Trailer", "X-Stream-Error") total := 0 var err error var ok bool var item interface{} for { item, ok, err = next() if total == 0 { if err != nil { st := http.StatusInternalServerError w.WriteHeader(st) errorResp := api.config.APIErrorFunc(err, st) api.config.Logger.Errorf("sending error response: %d: %s", st, err.Error()) if err := enc.Encode(errorResp); err != nil { api.config.Logger.Error(err) } return } if !ok { // but no error. w.WriteHeader(http.StatusNoContent) return } w.WriteHeader(http.StatusOK) } if err != nil { break } // finish just fine if !ok { break } // we have an item total++ err = enc.Encode(item) if err != nil { api.config.Logger.Error(err) break } if flush { flusher.Flush() } } if err != nil { w.Header().Set("X-Stream-Error", err.Error()) } else { // Due to some Javascript-browser-land stuff, we set the header // even when there is no error. w.Header().Set("X-Stream-Error", "") } // check for function errors for funcErr := range errCh { if funcErr != nil { w.Header().Add("X-Stream-Error", funcErr.Error()) } } } // SetHeaders sets all the headers that are common to all responses // from this API. Called automatically from SendResponse(). func (api *API) SetHeaders(w http.ResponseWriter) { for header, values := range api.config.Headers { for _, val := range values { w.Header().Add(header, val) } } w.Header().Add("Content-Type", "application/json") } // These functions below are mostly used in tests. // HTTPAddresses returns the HTTP(s) listening address // in host:port format. Useful when configured to start // on a random port (0). Returns error when the HTTP endpoint // is not enabled. func (api *API) HTTPAddresses() ([]string, error) { if len(api.httpListeners) == 0 { return nil, ErrHTTPEndpointNotEnabled } var addrs []string for _, l := range api.httpListeners { addrs = append(addrs, l.Addr().String()) } return addrs, nil } // Host returns the libp2p Host used by the API, if any. // The result is either the host provided during initialization, // a default Host created with options from the configuration object, // or nil. func (api *API) Host() host.Host { return api.host } // Headers returns the configured Headers. // Useful for testing. func (api *API) Headers() map[string][]string { return api.config.Headers } // SetKeepAlivesEnabled controls the HTTP server Keep Alive settings. Useful // for testing. func (api *API) SetKeepAlivesEnabled(b bool) { api.server.SetKeepAlivesEnabled(b) }