diff --git a/api/api.go b/api/api.go new file mode 100644 index 0000000..adb8f34 --- /dev/null +++ b/api/api.go @@ -0,0 +1,167 @@ +// Copyright 2015 The Chihaya Authors. All rights reserved. +// Use of this source code is governed by the BSD 2-Clause license, +// which can be found in the LICENSE file. + +// Package api implements a RESTful HTTP JSON API server for a BitTorrent +// tracker. +package api + +import ( + "net" + "net/http" + "time" + + "github.com/golang/glog" + "github.com/julienschmidt/httprouter" + "github.com/tylerb/graceful" + + "github.com/chihaya/chihaya/config" + "github.com/chihaya/chihaya/stats" + "github.com/chihaya/chihaya/tracker" +) + +// Server represents an API server for a torrent tracker. +type Server struct { + config *config.Config + tracker *tracker.Tracker + grace *graceful.Server + stopping bool +} + +// NewServer returns a new API server for a given configuration and tracker +// instance. +func NewServer(cfg *config.Config, tkr *tracker.Tracker) *Server { + return &Server{ + config: cfg, + tracker: tkr, + } +} + +// Stop cleanly shuts down the server. +func (s *Server) Stop() { + if !s.stopping { + s.grace.Stop(s.grace.Timeout) + } +} + +// Serve runs an API server, blocking until the server has shut down. +func (s *Server) Serve() { + glog.V(0).Info("Starting API on ", s.config.APIConfig.ListenAddr) + + if s.config.APIConfig.ListenLimit != 0 { + glog.V(0).Info("Limiting connections to ", s.config.APIConfig.ListenLimit) + } + + grace := &graceful.Server{ + Timeout: s.config.APIConfig.RequestTimeout.Duration, + ConnState: s.connState, + ListenLimit: s.config.APIConfig.ListenLimit, + + NoSignalHandling: true, + Server: &http.Server{ + Addr: s.config.APIConfig.ListenAddr, + Handler: newRouter(s), + ReadTimeout: s.config.APIConfig.ReadTimeout.Duration, + WriteTimeout: s.config.APIConfig.WriteTimeout.Duration, + }, + } + + s.grace = grace + grace.SetKeepAlivesEnabled(false) + grace.ShutdownInitiated = func() { s.stopping = true } + + if err := grace.ListenAndServe(); err != nil { + if opErr, ok := err.(*net.OpError); !ok || (ok && opErr.Op != "accept") { + glog.Errorf("Failed to gracefully run API server: %s", err.Error()) + return + } + } + + glog.Info("API server shut down cleanly") +} + +// newRouter returns a router with all the routes. +func newRouter(s *Server) *httprouter.Router { + r := httprouter.New() + + if s.config.PrivateEnabled { + r.PUT("/users/:passkey", makeHandler(s.putUser)) + r.DELETE("/users/:passkey", makeHandler(s.delUser)) + } + + if s.config.ClientWhitelistEnabled { + r.GET("/clients/:clientID", makeHandler(s.getClient)) + r.PUT("/clients/:clientID", makeHandler(s.putClient)) + r.DELETE("/clients/:clientID", makeHandler(s.delClient)) + } + + r.GET("/torrents/:infohash", makeHandler(s.getTorrent)) + r.PUT("/torrents/:infohash", makeHandler(s.putTorrent)) + r.DELETE("/torrents/:infohash", makeHandler(s.delTorrent)) + r.GET("/check", makeHandler(s.check)) + r.GET("/stats", makeHandler(s.stats)) + + return r +} + +// connState is used by graceful in order to gracefully shutdown. It also +// keeps track of connection stats. +func (s *Server) connState(conn net.Conn, state http.ConnState) { + switch state { + case http.StateNew: + stats.RecordEvent(stats.AcceptedConnection) + + case http.StateClosed: + stats.RecordEvent(stats.ClosedConnection) + + case http.StateHijacked: + panic("connection impossibly hijacked") + + // Ignore the following cases. + case http.StateActive, http.StateIdle: + + default: + glog.Errorf("Connection transitioned to unknown state %s (%d)", state, state) + } +} + +// ResponseHandler is an HTTP handler that returns a status code. +type ResponseHandler func(http.ResponseWriter, *http.Request, httprouter.Params) (int, error) + +// makeHandler wraps our ResponseHandlers while timing requests, collecting, +// stats, logging, and handling errors. +func makeHandler(handler ResponseHandler) httprouter.Handle { + return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { + start := time.Now() + httpCode, err := handler(w, r, p) + duration := time.Since(start) + + var msg string + if err != nil { + msg = err.Error() + } else if httpCode != http.StatusOK { + msg = http.StatusText(httpCode) + } + + if len(msg) > 0 { + http.Error(w, msg, httpCode) + stats.RecordEvent(stats.ErroredRequest) + } + + if len(msg) > 0 || glog.V(2) { + reqString := r.URL.Path + " " + r.RemoteAddr + if glog.V(3) { + reqString = r.URL.RequestURI() + " " + r.RemoteAddr + } + + if len(msg) > 0 { + glog.Errorf("[API - %9s] %s (%d - %s)", duration, reqString, httpCode, msg) + } else { + glog.Infof("[API - %9s] %s (%d)", duration, reqString, httpCode) + } + } + + stats.RecordEvent(stats.HandledRequest) + stats.RecordTiming(stats.ResponseTime, duration) + } +} diff --git a/api/routes.go b/api/routes.go new file mode 100644 index 0000000..4786ecd --- /dev/null +++ b/api/routes.go @@ -0,0 +1,167 @@ +// Copyright 2015 The Chihaya Authors. All rights reserved. +// Use of this source code is governed by the BSD 2-Clause license, +// which can be found in the LICENSE file. + +package api + +import ( + "encoding/json" + "io/ioutil" + "net/http" + "net/url" + "runtime" + + "github.com/julienschmidt/httprouter" + + "github.com/chihaya/chihaya/stats" + "github.com/chihaya/chihaya/tracker/models" +) + +const jsonContentType = "application/json; charset=UTF-8" + +func handleError(err error) (int, error) { + if err == nil { + return http.StatusOK, nil + } else if _, ok := err.(models.NotFoundError); ok { + stats.RecordEvent(stats.ClientError) + return http.StatusNotFound, nil + } else if _, ok := err.(models.ClientError); ok { + stats.RecordEvent(stats.ClientError) + return http.StatusBadRequest, nil + } + return http.StatusInternalServerError, err +} + +func (s *Server) check(w http.ResponseWriter, r *http.Request, p httprouter.Params) (int, error) { + // Attempt to ping the backend if private tracker is enabled. + if s.config.PrivateEnabled { + if err := s.tracker.Backend.Ping(); err != nil { + return handleError(err) + } + } + + _, err := w.Write([]byte("STILL-ALIVE")) + return handleError(err) +} + +func (s *Server) stats(w http.ResponseWriter, r *http.Request, p httprouter.Params) (int, error) { + w.Header().Set("Content-Type", jsonContentType) + + var err error + var val interface{} + query := r.URL.Query() + + stats.DefaultStats.GoRoutines = runtime.NumGoroutine() + + if _, flatten := query["flatten"]; flatten { + val = stats.DefaultStats.Flattened() + } else { + val = stats.DefaultStats + } + + if _, pretty := query["pretty"]; pretty { + var buf []byte + buf, err = json.MarshalIndent(val, "", " ") + + if err == nil { + _, err = w.Write(buf) + } + } else { + err = json.NewEncoder(w).Encode(val) + } + + return handleError(err) +} + +func (s *Server) getTorrent(w http.ResponseWriter, r *http.Request, p httprouter.Params) (int, error) { + infohash, err := url.QueryUnescape(p.ByName("infohash")) + if err != nil { + return http.StatusNotFound, err + } + + torrent, err := s.tracker.FindTorrent(infohash) + if err != nil { + return handleError(err) + } + + w.Header().Set("Content-Type", jsonContentType) + e := json.NewEncoder(w) + return handleError(e.Encode(torrent)) +} + +func (s *Server) putTorrent(w http.ResponseWriter, r *http.Request, p httprouter.Params) (int, error) { + body, err := ioutil.ReadAll(r.Body) + if err != nil { + return http.StatusInternalServerError, err + } + + var torrent models.Torrent + err = json.Unmarshal(body, &torrent) + if err != nil { + return http.StatusBadRequest, err + } + + s.tracker.PutTorrent(&torrent) + return http.StatusOK, nil +} + +func (s *Server) delTorrent(w http.ResponseWriter, r *http.Request, p httprouter.Params) (int, error) { + infohash, err := url.QueryUnescape(p.ByName("infohash")) + if err != nil { + return http.StatusNotFound, err + } + + s.tracker.DeleteTorrent(infohash) + return http.StatusOK, nil +} + +func (s *Server) getUser(w http.ResponseWriter, r *http.Request, p httprouter.Params) (int, error) { + user, err := s.tracker.FindUser(p.ByName("passkey")) + if err == models.ErrUserDNE { + return http.StatusNotFound, err + } else if err != nil { + return http.StatusInternalServerError, err + } + + w.Header().Set("Content-Type", jsonContentType) + e := json.NewEncoder(w) + return handleError(e.Encode(user)) +} + +func (s *Server) putUser(w http.ResponseWriter, r *http.Request, p httprouter.Params) (int, error) { + body, err := ioutil.ReadAll(r.Body) + if err != nil { + return http.StatusInternalServerError, err + } + + var user models.User + err = json.Unmarshal(body, &user) + if err != nil { + return http.StatusBadRequest, err + } + + s.tracker.PutUser(&user) + return http.StatusOK, nil +} + +func (s *Server) delUser(w http.ResponseWriter, r *http.Request, p httprouter.Params) (int, error) { + s.tracker.DeleteUser(p.ByName("passkey")) + return http.StatusOK, nil +} + +func (s *Server) getClient(w http.ResponseWriter, r *http.Request, p httprouter.Params) (int, error) { + if err := s.tracker.ClientApproved(p.ByName("clientID")); err != nil { + return http.StatusNotFound, err + } + return http.StatusOK, nil +} + +func (s *Server) putClient(w http.ResponseWriter, r *http.Request, p httprouter.Params) (int, error) { + s.tracker.PutClient(p.ByName("clientID")) + return http.StatusOK, nil +} + +func (s *Server) delClient(w http.ResponseWriter, r *http.Request, p httprouter.Params) (int, error) { + s.tracker.DeleteClient(p.ByName("clientID")) + return http.StatusOK, nil +} diff --git a/http/http.go b/http/http.go index 8a10a5f..df0ac2c 100644 --- a/http/http.go +++ b/http/http.go @@ -2,7 +2,8 @@ // Use of this source code is governed by the BSD 2-Clause license, // which can be found in the LICENSE file. -// Package http implements an http-serving BitTorrent tracker. +// Package http implements a BitTorrent tracker over the HTTP protocol as per +// BEP 3. package http import ( @@ -75,26 +76,11 @@ func newRouter(s *Server) *httprouter.Router { if s.config.PrivateEnabled { r.GET("/users/:passkey/announce", makeHandler(s.serveAnnounce)) r.GET("/users/:passkey/scrape", makeHandler(s.serveScrape)) - - r.PUT("/users/:passkey", makeHandler(s.putUser)) - r.DELETE("/users/:passkey", makeHandler(s.delUser)) } else { r.GET("/announce", makeHandler(s.serveAnnounce)) r.GET("/scrape", makeHandler(s.serveScrape)) } - if s.config.ClientWhitelistEnabled { - r.GET("/clients/:clientID", makeHandler(s.getClient)) - r.PUT("/clients/:clientID", makeHandler(s.putClient)) - r.DELETE("/clients/:clientID", makeHandler(s.delClient)) - } - - r.GET("/torrents/:infohash", makeHandler(s.getTorrent)) - r.PUT("/torrents/:infohash", makeHandler(s.putTorrent)) - r.DELETE("/torrents/:infohash", makeHandler(s.delTorrent)) - r.GET("/check", makeHandler(s.check)) - r.GET("/stats", makeHandler(s.stats)) - return r } @@ -120,24 +106,24 @@ func (s *Server) connState(conn net.Conn, state http.ConnState) { } // Serve runs an HTTP server, blocking until the server has shut down. -func (s *Server) Serve(addr string) { - glog.V(0).Info("Starting HTTP on ", addr) +func (s *Server) Serve() { + glog.V(0).Info("Starting HTTP on ", s.config.HTTPConfig.ListenAddr) - if s.config.HTTPListenLimit != 0 { - glog.V(0).Info("Limiting connections to ", s.config.HTTPListenLimit) + if s.config.HTTPConfig.ListenLimit != 0 { + glog.V(0).Info("Limiting connections to ", s.config.HTTPConfig.ListenLimit) } grace := &graceful.Server{ - Timeout: s.config.HTTPRequestTimeout.Duration, + Timeout: s.config.HTTPConfig.RequestTimeout.Duration, ConnState: s.connState, - ListenLimit: s.config.HTTPListenLimit, + ListenLimit: s.config.HTTPConfig.ListenLimit, NoSignalHandling: true, Server: &http.Server{ - Addr: addr, + Addr: s.config.HTTPConfig.ListenAddr, Handler: newRouter(s), - ReadTimeout: s.config.HTTPReadTimeout.Duration, - WriteTimeout: s.config.HTTPWriteTimeout.Duration, + ReadTimeout: s.config.HTTPConfig.ReadTimeout.Duration, + WriteTimeout: s.config.HTTPConfig.WriteTimeout.Duration, }, } diff --git a/http/routes.go b/http/routes.go index b405b65..4dea41b 100644 --- a/http/routes.go +++ b/http/routes.go @@ -5,11 +5,7 @@ package http import ( - "encoding/json" - "io/ioutil" "net/http" - "net/url" - "runtime" "github.com/julienschmidt/httprouter" @@ -17,62 +13,6 @@ import ( "github.com/chihaya/chihaya/tracker/models" ) -const jsonContentType = "application/json; charset=UTF-8" - -func handleError(err error) (int, error) { - if err == nil { - return http.StatusOK, nil - } else if _, ok := err.(models.NotFoundError); ok { - stats.RecordEvent(stats.ClientError) - return http.StatusNotFound, nil - } else if _, ok := err.(models.ClientError); ok { - stats.RecordEvent(stats.ClientError) - return http.StatusBadRequest, nil - } - return http.StatusInternalServerError, err -} - -func (s *Server) check(w http.ResponseWriter, r *http.Request, p httprouter.Params) (int, error) { - // Attempt to ping the backend if private tracker is enabled. - if s.config.PrivateEnabled { - if err := s.tracker.Backend.Ping(); err != nil { - return handleError(err) - } - } - - _, err := w.Write([]byte("STILL-ALIVE")) - return handleError(err) -} - -func (s *Server) stats(w http.ResponseWriter, r *http.Request, p httprouter.Params) (int, error) { - w.Header().Set("Content-Type", jsonContentType) - - var err error - var val interface{} - query := r.URL.Query() - - stats.DefaultStats.GoRoutines = runtime.NumGoroutine() - - if _, flatten := query["flatten"]; flatten { - val = stats.DefaultStats.Flattened() - } else { - val = stats.DefaultStats - } - - if _, pretty := query["pretty"]; pretty { - var buf []byte - buf, err = json.MarshalIndent(val, "", " ") - - if err == nil { - _, err = w.Write(buf) - } - } else { - err = json.NewEncoder(w).Encode(val) - } - - return handleError(err) -} - func handleTorrentError(err error, w *Writer) (int, error) { if err == nil { return http.StatusOK, nil @@ -104,96 +44,3 @@ func (s *Server) serveScrape(w http.ResponseWriter, r *http.Request, p httproute return handleTorrentError(s.tracker.HandleScrape(scrape, writer), writer) } - -func (s *Server) getTorrent(w http.ResponseWriter, r *http.Request, p httprouter.Params) (int, error) { - infohash, err := url.QueryUnescape(p.ByName("infohash")) - if err != nil { - return http.StatusNotFound, err - } - - torrent, err := s.tracker.FindTorrent(infohash) - if err != nil { - return handleError(err) - } - - w.Header().Set("Content-Type", jsonContentType) - e := json.NewEncoder(w) - return handleError(e.Encode(torrent)) -} - -func (s *Server) putTorrent(w http.ResponseWriter, r *http.Request, p httprouter.Params) (int, error) { - body, err := ioutil.ReadAll(r.Body) - if err != nil { - return http.StatusInternalServerError, err - } - - var torrent models.Torrent - err = json.Unmarshal(body, &torrent) - if err != nil { - return http.StatusBadRequest, err - } - - s.tracker.PutTorrent(&torrent) - return http.StatusOK, nil -} - -func (s *Server) delTorrent(w http.ResponseWriter, r *http.Request, p httprouter.Params) (int, error) { - infohash, err := url.QueryUnescape(p.ByName("infohash")) - if err != nil { - return http.StatusNotFound, err - } - - s.tracker.DeleteTorrent(infohash) - return http.StatusOK, nil -} - -func (s *Server) getUser(w http.ResponseWriter, r *http.Request, p httprouter.Params) (int, error) { - user, err := s.tracker.FindUser(p.ByName("passkey")) - if err == models.ErrUserDNE { - return http.StatusNotFound, err - } else if err != nil { - return http.StatusInternalServerError, err - } - - w.Header().Set("Content-Type", jsonContentType) - e := json.NewEncoder(w) - return handleError(e.Encode(user)) -} - -func (s *Server) putUser(w http.ResponseWriter, r *http.Request, p httprouter.Params) (int, error) { - body, err := ioutil.ReadAll(r.Body) - if err != nil { - return http.StatusInternalServerError, err - } - - var user models.User - err = json.Unmarshal(body, &user) - if err != nil { - return http.StatusBadRequest, err - } - - s.tracker.PutUser(&user) - return http.StatusOK, nil -} - -func (s *Server) delUser(w http.ResponseWriter, r *http.Request, p httprouter.Params) (int, error) { - s.tracker.DeleteUser(p.ByName("passkey")) - return http.StatusOK, nil -} - -func (s *Server) getClient(w http.ResponseWriter, r *http.Request, p httprouter.Params) (int, error) { - if err := s.tracker.ClientApproved(p.ByName("clientID")); err != nil { - return http.StatusNotFound, err - } - return http.StatusOK, nil -} - -func (s *Server) putClient(w http.ResponseWriter, r *http.Request, p httprouter.Params) (int, error) { - s.tracker.PutClient(p.ByName("clientID")) - return http.StatusOK, nil -} - -func (s *Server) delClient(w http.ResponseWriter, r *http.Request, p httprouter.Params) (int, error) { - s.tracker.DeleteClient(p.ByName("clientID")) - return http.StatusOK, nil -}