diff --git a/internal/api/activitypub.go b/internal/api/activitypub.go index 02ae0767c..0c0222d1c 100644 --- a/internal/api/activitypub.go +++ b/internal/api/activitypub.go @@ -35,7 +35,7 @@ type ActivityPub struct { signatureCheckMiddleware gin.HandlerFunc } -func (a *ActivityPub) Route(r router.Router, m ...gin.HandlerFunc) { +func (a *ActivityPub) Route(r *router.Router, m ...gin.HandlerFunc) { // create groupings for the 'emoji' and 'users' prefixes emojiGroup := r.AttachGroup("emoji") usersGroup := r.AttachGroup("users") @@ -54,7 +54,7 @@ func (a *ActivityPub) Route(r router.Router, m ...gin.HandlerFunc) { } // Public key endpoint requires different middleware + cache policies from other AP endpoints. -func (a *ActivityPub) RoutePublicKey(r router.Router, m ...gin.HandlerFunc) { +func (a *ActivityPub) RoutePublicKey(r *router.Router, m ...gin.HandlerFunc) { // Create grouping for the 'users/[username]/main-key' prefix. publicKeyGroup := r.AttachGroup(publickey.PublicKeyPath) diff --git a/internal/api/auth.go b/internal/api/auth.go index 961caa981..8d7808a3a 100644 --- a/internal/api/auth.go +++ b/internal/api/auth.go @@ -36,7 +36,7 @@ type Auth struct { } // Route attaches 'auth' and 'oauth' groups to the given router. -func (a *Auth) Route(r router.Router, m ...gin.HandlerFunc) { +func (a *Auth) Route(r *router.Router, m ...gin.HandlerFunc) { // create groupings for the 'auth' and 'oauth' prefixes authGroup := r.AttachGroup("auth") oauthGroup := r.AttachGroup("oauth") diff --git a/internal/api/client.go b/internal/api/client.go index ec8fa6034..1112efa31 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -79,7 +79,7 @@ type Client struct { user *user.Module // api/v1/user } -func (c *Client) Route(r router.Router, m ...gin.HandlerFunc) { +func (c *Client) Route(r *router.Router, m ...gin.HandlerFunc) { // create a new group on the top level client 'api' prefix apiGroup := r.AttachGroup("api") diff --git a/internal/api/fileserver.go b/internal/api/fileserver.go index 8f1e60b82..59f38c362 100644 --- a/internal/api/fileserver.go +++ b/internal/api/fileserver.go @@ -30,7 +30,7 @@ type Fileserver struct { fileserver *fileserver.Module } -func (f *Fileserver) Route(r router.Router, m ...gin.HandlerFunc) { +func (f *Fileserver) Route(r *router.Router, m ...gin.HandlerFunc) { fileserverGroup := r.AttachGroup("fileserver") // Attach middlewares appropriate for this group. diff --git a/internal/api/nodeinfo.go b/internal/api/nodeinfo.go index 22d314033..fb7918edc 100644 --- a/internal/api/nodeinfo.go +++ b/internal/api/nodeinfo.go @@ -29,7 +29,7 @@ type NodeInfo struct { nodeInfo *nodeinfo.Module } -func (w *NodeInfo) Route(r router.Router, m ...gin.HandlerFunc) { +func (w *NodeInfo) Route(r *router.Router, m ...gin.HandlerFunc) { // group nodeinfo endpoints together nodeInfoGroup := r.AttachGroup("nodeinfo") diff --git a/internal/api/util/errorhandling.go b/internal/api/util/errorhandling.go index 33f501474..4fa544ffd 100644 --- a/internal/api/util/errorhandling.go +++ b/internal/api/util/errorhandling.go @@ -19,6 +19,7 @@ package util import ( "context" + "errors" "net/http" "codeberg.org/gruf/go-kv" @@ -90,19 +91,40 @@ func genericErrorHandler(c *gin.Context, instanceGet func(ctx context.Context) ( // try to serve an appropriate application/json content-type error. // To override the default response type, specify `offers`. // -// If the requester already hung up on the request, ErrorHandler -// will overwrite the given errWithCode with a 499 error to indicate -// that the failure wasn't due to something we did, and will avoid -// trying to write extensive bytes to the caller by just aborting. +// If the requester already hung up on the request, or the server +// timed out a very slow request, ErrorHandler will overwrite the +// given errWithCode with a 408 or 499 error to indicate that the +// failure wasn't due to something we did, and will avoid trying +// to write extensive bytes to the caller by just aborting. // -// See: https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#nginx. -func ErrorHandler(c *gin.Context, errWithCode gtserror.WithCode, instanceGet func(ctx context.Context) (*apimodel.InstanceV1, gtserror.WithCode), offers ...MIME) { - if c.Request.Context().Err() != nil { - // Context error means requester probably left already. - // Wrap original error with a less alarming one. Then - // we can return early, because it doesn't matter what - // we send to the client at this point; they're gone. - errWithCode = gtserror.NewErrorClientClosedRequest(errWithCode.Unwrap()) +// For 499, see https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#nginx. +func ErrorHandler( + c *gin.Context, + errWithCode gtserror.WithCode, + instanceGet func(ctx context.Context) (*apimodel.InstanceV1, gtserror.WithCode), + offers ...MIME, +) { + if ctxErr := c.Request.Context().Err(); ctxErr != nil { + // Context error means either client has left already, + // or server has timed out a very slow request. + // + // Rewrap the error with something less scary, + // and just abort the request gracelessly. + err := errWithCode.Unwrap() + + if errors.Is(ctxErr, context.DeadlineExceeded) { + // We timed out the request. + errWithCode = gtserror.NewErrorRequestTimeout(err) + + // Be correct and write "close". + // See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Connection#close + // and: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/408 + c.Header("Connection", "close") + } else { + // Client timed out the request. + errWithCode = gtserror.NewErrorClientClosedRequest(err) + } + c.AbortWithStatus(errWithCode.Code()) return } diff --git a/internal/api/wellknown.go b/internal/api/wellknown.go index 63ca48ef7..90e18d637 100644 --- a/internal/api/wellknown.go +++ b/internal/api/wellknown.go @@ -33,7 +33,7 @@ type WellKnown struct { hostMeta *hostmeta.Module } -func (w *WellKnown) Route(r router.Router, m ...gin.HandlerFunc) { +func (w *WellKnown) Route(r *router.Router, m ...gin.HandlerFunc) { // group .well-known endpoints together wellKnownGroup := r.AttachGroup(".well-known") diff --git a/internal/gotosocial/gotosocial.go b/internal/gotosocial/gotosocial.go index 74cc09f65..e6a3934be 100644 --- a/internal/gotosocial/gotosocial.go +++ b/internal/gotosocial/gotosocial.go @@ -29,7 +29,7 @@ import ( // GoToSocial server instance. type Server struct { db db.DB - apiRouter router.Router + apiRouter *router.Router cleaner *cleaner.Cleaner } @@ -37,7 +37,7 @@ type Server struct { // GoToSocial server instance. func NewServer( db db.DB, - apiRouter router.Router, + apiRouter *router.Router, cleaner *cleaner.Cleaner, ) *Server { return &Server{ diff --git a/internal/gtserror/withcode.go b/internal/gtserror/withcode.go index 55fe7502a..d17a4e42e 100644 --- a/internal/gtserror/withcode.go +++ b/internal/gtserror/withcode.go @@ -198,3 +198,14 @@ func NewErrorClientClosedRequest(original error) WithCode { code: StatusClientClosedRequest, } } + +// NewErrorRequestTimeout returns an ErrorWithCode 408 with the given original error. +// This error type should only be used when the server has decided to hang up a client +// request after x amount of time, to avoid keeping extremely slow client requests open. +func NewErrorRequestTimeout(original error) WithCode { + return withCode{ + original: original, + safe: errors.New(http.StatusText(http.StatusRequestTimeout)), + code: http.StatusRequestTimeout, + } +} diff --git a/internal/router/attach.go b/internal/router/attach.go index 40e5992a4..4b4fd7ac4 100644 --- a/internal/router/attach.go +++ b/internal/router/attach.go @@ -19,18 +19,18 @@ package router import "github.com/gin-gonic/gin" -func (r *router) AttachGlobalMiddleware(handlers ...gin.HandlerFunc) gin.IRoutes { +func (r *Router) AttachGlobalMiddleware(handlers ...gin.HandlerFunc) gin.IRoutes { return r.engine.Use(handlers...) } -func (r *router) AttachNoRouteHandler(handler gin.HandlerFunc) { +func (r *Router) AttachNoRouteHandler(handler gin.HandlerFunc) { r.engine.NoRoute(handler) } -func (r *router) AttachGroup(relativePath string, handlers ...gin.HandlerFunc) *gin.RouterGroup { +func (r *Router) AttachGroup(relativePath string, handlers ...gin.HandlerFunc) *gin.RouterGroup { return r.engine.Group(relativePath, handlers...) } -func (r *router) AttachHandler(method string, path string, handler gin.HandlerFunc) { +func (r *Router) AttachHandler(method string, path string, handler gin.HandlerFunc) { r.engine.Handle(method, path, handler) } diff --git a/internal/router/router.go b/internal/router/router.go index b6991f97f..f71dc97ef 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -29,6 +29,7 @@ import ( "codeberg.org/gruf/go-debug" "github.com/gin-gonic/gin" "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/log" "golang.org/x/crypto/acme/autocert" ) @@ -42,94 +43,120 @@ const ( maxMultipartMemory = int64(8 * bytesize.MiB) ) -// Router provides the REST interface for gotosocial, using gin. -type Router interface { - // Attach global gin middlewares to this router. - AttachGlobalMiddleware(handlers ...gin.HandlerFunc) gin.IRoutes - // AttachGroup attaches the given handlers into a group with the given relativePath as - // base path for that group. It then returns the *gin.RouterGroup so that the caller - // can add any extra middlewares etc specific to that group, as desired. - AttachGroup(relativePath string, handlers ...gin.HandlerFunc) *gin.RouterGroup - // Attach a single gin handler to the router with the given method and path. - // To make middleware management easier, AttachGroup should be preferred where possible. - // However, this function can be used for attaching single handlers that only require - // global middlewares. - AttachHandler(method string, path string, handler gin.HandlerFunc) - - // Attach 404 NoRoute handler - AttachNoRouteHandler(handler gin.HandlerFunc) - // Start the router - Start() - // Stop the router - Stop(ctx context.Context) error +// Router provides the HTTP REST +// interface for GoToSocial, using gin. +type Router struct { + engine *gin.Engine + srv *http.Server } -// router fulfils the Router interface using gin and logrus -type router struct { - engine *gin.Engine - srv *http.Server - certManager *autocert.Manager -} +// New returns a new Router, which wraps +// an http server and gin handler engine. +// +// The router's Attach functions should be +// used *before* the router is Started. +// +// When the router's work is finished, Stop +// should be called on it to close connections +// gracefully. +// +// The provided context will be used as the base +// context for all requests passing through the +// underlying http.Server, so this should be a +// long-running context. +func New(ctx context.Context) (*Router, error) { + // TODO: make this configurable? + gin.SetMode(gin.ReleaseMode) -// Start starts the router nicely. It will serve two handlers if letsencrypt is enabled, and only the web/API handler if letsencrypt is not enabled. -func (r *router) Start() { - // listen is the server start function, by - // default pointing to regular HTTP listener, - // but updated to TLS if LetsEncrypt is enabled. - listen := r.srv.ListenAndServe + // Create the engine here -- this is the core + // request routing handler for GoToSocial. + engine := gin.New() + engine.MaxMultipartMemory = maxMultipartMemory + engine.HandleMethodNotAllowed = true - // During config validation we already checked that both Chain and Key are set - // so we can forego checking for both here - if chain := config.GetTLSCertificateChain(); chain != "" { - pkey := config.GetTLSCertificateKey() - cer, err := tls.LoadX509KeyPair(chain, pkey) - if err != nil { - log.Fatalf( - nil, - "tls: failed to load keypair from %s and %s, ensure they are PEM-encoded and can be read by this process: %s", - chain, pkey, err, - ) - } - r.srv.TLSConfig = &tls.Config{ - MinVersion: tls.VersionTLS12, - Certificates: []tls.Certificate{cer}, - } - // TLS is enabled, update the listen function - listen = func() error { return r.srv.ListenAndServeTLS("", "") } + // Set up client IP forwarding via + // trusted x-forwarded-* headers. + trustedProxies := config.GetTrustedProxies() + if err := engine.SetTrustedProxies(trustedProxies); err != nil { + return nil, err } - if config.GetLetsEncryptEnabled() { - // LetsEncrypt support is enabled + // Attach functions used by HTML templating, + // and load HTML templates into the engine. + LoadTemplateFunctions(engine) + if err := LoadTemplates(engine); err != nil { + return nil, err + } - // Prepare an HTTPS-redirect handler for LetsEncrypt fallback - redirect := http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - target := "https://" + r.Host + r.URL.Path - if len(r.URL.RawQuery) > 0 { - target += "?" + r.URL.RawQuery - } - http.Redirect(rw, r, target, http.StatusTemporaryRedirect) - }) + // Use the passed-in cmd context as the base context for the + // server, since we'll never want the server to live past the + // `server start` command anyway. + baseCtx := func(_ net.Listener) context.Context { return ctx } - go func() { - // Take our own copy of HTTP server - // with updated autocert manager endpoint - srv := (*r.srv) //nolint - srv.Handler = r.certManager.HTTPHandler(redirect) - srv.Addr = fmt.Sprintf("%s:%d", - config.GetBindAddress(), - config.GetLetsEncryptPort(), - ) + addr := fmt.Sprintf("%s:%d", + config.GetBindAddress(), + config.GetPort(), + ) - // Start the LetsEncrypt autocert manager HTTP server. - log.Infof(nil, "letsencrypt listening on %s", srv.Addr) - if err := srv.ListenAndServe(); err != nil && - err != http.ErrServerClosed { - log.Fatalf(nil, "letsencrypt: listen: %s", err) - } - }() + // Wrap the gin engine handler in our + // own timeout handler, to ensure we + // don't keep very slow requests around. + handler := timeoutHandler{engine} - // TLS is enabled, update the listen function - listen = func() error { return r.srv.ListenAndServeTLS("", "") } + s := &http.Server{ + Addr: addr, + Handler: handler, + ReadTimeout: readTimeout, + ReadHeaderTimeout: readHeaderTimeout, + WriteTimeout: writeTimeout, + IdleTimeout: idleTimeout, + BaseContext: baseCtx, + } + + return &Router{ + engine: engine, + srv: s, + }, nil +} + +// Start starts the router nicely. +// +// It will serve two handlers if letsencrypt is enabled, +// and only the web/API handler if letsencrypt is not enabled. +func (r *Router) Start() { + var ( + // listen is the server start function. + // By default this points to a regular + // HTTP listener, but will be changed to + // TLS if custom certs or LE are enabled. + listen func() error + err error + + certFile = config.GetTLSCertificateChain() + keyFile = config.GetTLSCertificateKey() + leEnabled = config.GetLetsEncryptEnabled() + ) + + switch { + // TLS with custom certs. + case certFile != "": + // During config validation we already checked + // that either both or neither of Chain and Key + // are set, so we can forego checking again here. + listen, err = r.customTLS(certFile, keyFile) + + // TLS with letsencrypt. + case leEnabled: + listen, err = r.letsEncryptTLS() + + // Default listen. TLS must + // be handled by reverse proxy. + default: + listen = r.srv.ListenAndServe + } + + if err != nil { + log.Fatal(nil, err) } // Pass the server handler through a debug pprof middleware handler. @@ -154,7 +181,7 @@ func (r *router) Start() { } // Stop shuts down the router nicely -func (r *router) Stop(ctx context.Context) error { +func (r *Router) Stop(ctx context.Context) error { log.Infof(nil, "shutting down http router with %s grace period", shutdownTimeout) timeout, cancel := context.WithTimeout(ctx, shutdownTimeout) defer cancel() @@ -167,78 +194,87 @@ func (r *router) Stop(ctx context.Context) error { return nil } -// New returns a new Router. -// -// The router's Attach functions should be used *before* the router is Started. -// -// When the router's work is finished, Stop should be called on it to close connections gracefully. -// -// The provided context will be used as the base context for all requests passing -// through the underlying http.Server, so this should be a long-running context. -func New(ctx context.Context) (Router, error) { - gin.SetMode(gin.TestMode) - - // create the actual engine here -- this is the core request routing handler for gts - engine := gin.New() - engine.MaxMultipartMemory = maxMultipartMemory - engine.HandleMethodNotAllowed = true - - // set up IP forwarding via x-forward-* headers. - trustedProxies := config.GetTrustedProxies() - if err := engine.SetTrustedProxies(trustedProxies); err != nil { +// customTLS modifies the router's underlying +// http server to use custom TLS cert/key pair. +func (r *Router) customTLS( + certFile string, + keyFile string, +) (func() error, error) { + // Load certificates from disk. + cer, err := tls.LoadX509KeyPair(certFile, keyFile) + if err != nil { + err = gtserror.Newf( + "failed to load keypair from %s and %s, ensure they are "+ + "PEM-encoded and can be read by this process: %w", + certFile, keyFile, err, + ) return nil, err } - // set template functions - LoadTemplateFunctions(engine) - - // load templates onto the engine - if err := LoadTemplates(engine); err != nil { - return nil, err + // Override server's TLSConfig. + r.srv.TLSConfig = &tls.Config{ + MinVersion: tls.VersionTLS12, + Certificates: []tls.Certificate{cer}, } - // use the passed-in command context as the base context for the server, - // since we'll never want the server to live past the command anyway - baseCtx := func(_ net.Listener) context.Context { - return ctx - } - - bindAddress := config.GetBindAddress() - port := config.GetPort() - addr := fmt.Sprintf("%s:%d", bindAddress, port) - - s := &http.Server{ - Addr: addr, - Handler: engine, // use gin engine as handler - ReadTimeout: readTimeout, - ReadHeaderTimeout: readHeaderTimeout, - WriteTimeout: writeTimeout, - IdleTimeout: idleTimeout, - BaseContext: baseCtx, - } - - // We need to spawn the underlying server slightly differently depending on whether lets encrypt is enabled or not. - // In either case, the gin engine will still be used for routing requests. - leEnabled := config.GetLetsEncryptEnabled() - - var m *autocert.Manager - if leEnabled { - // le IS enabled, so roll up an autocert manager for handling letsencrypt requests - host := config.GetHost() - leCertDir := config.GetLetsEncryptCertDir() - leEmailAddress := config.GetLetsEncryptEmailAddress() - m = &autocert.Manager{ - Prompt: autocert.AcceptTOS, - HostPolicy: autocert.HostWhitelist(host), - Cache: autocert.DirCache(leCertDir), - Email: leEmailAddress, - } - s.TLSConfig = m.TLSConfig() - } - - return &router{ - engine: engine, - srv: s, - certManager: m, - }, nil + // Update listen function to use custom TLS. + listen := func() error { return r.srv.ListenAndServeTLS("", "") } + return listen, nil +} + +// letsEncryptTLS modifies the router's underlying http +// server to use LetsEncrypt via an ACME Autocert manager. +// +// It also starts a listener on the configured LetsEncrypt +// port to validate LE requests. +func (r *Router) letsEncryptTLS() (func() error, error) { + acm := &autocert.Manager{ + Prompt: autocert.AcceptTOS, + HostPolicy: autocert.HostWhitelist(config.GetHost()), + Cache: autocert.DirCache(config.GetLetsEncryptCertDir()), + Email: config.GetLetsEncryptEmailAddress(), + } + + // Override server's TLSConfig. + r.srv.TLSConfig = acm.TLSConfig() + + // Prepare a fallback handler for LetsEncrypt. + // + // This will redirect all non-LetsEncrypt http + // reqs to https, preserving path and query params. + var fallback http.HandlerFunc = func( + w http.ResponseWriter, + r *http.Request, + ) { + // Rewrite target to https. + target := "https://" + r.Host + r.URL.Path + if len(r.URL.RawQuery) > 0 { + target += "?" + r.URL.RawQuery + } + + http.Redirect(w, r, target, http.StatusTemporaryRedirect) + } + + // Take our own copy of the HTTP server, + // and update it to serve LetsEncrypt + // requests via the autocert manager. + leSrv := (*r.srv) //nolint:govet + leSrv.Handler = acm.HTTPHandler(fallback) + leSrv.Addr = fmt.Sprintf("%s:%d", + config.GetBindAddress(), + config.GetLetsEncryptPort(), + ) + + go func() { + // Start the LetsEncrypt autocert manager HTTP server. + log.Infof(nil, "letsencrypt listening on %s", leSrv.Addr) + if err := leSrv.ListenAndServe(); err != nil && + err != http.ErrServerClosed { + log.Fatalf(nil, "letsencrypt: listen: %s", err) + } + }() + + // Update listen function to use LetsEncrypt TLS. + listen := func() error { return r.srv.ListenAndServeTLS("", "") } + return listen, nil } diff --git a/internal/router/timeout.go b/internal/router/timeout.go new file mode 100644 index 000000000..0f1ef869b --- /dev/null +++ b/internal/router/timeout.go @@ -0,0 +1,61 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package router + +import ( + "context" + "net/http" + "time" + + "github.com/gin-gonic/gin" +) + +const requestTimeout = 10 * time.Minute + +type timeoutHandler struct { + *gin.Engine +} + +// ServeHTTP wraps the embedded Gin engine's ServeHTTP +// function with an injected context which times out +// non-upgraded inbound requests after 10 minutes. +func (th timeoutHandler) ServeHTTP( + w http.ResponseWriter, + r *http.Request, +) { + if upgr := r.Header.Get("Upgrade"); upgr != "" { + // Upgrade to wss (probably). + // Leave well enough alone. + th.Engine.ServeHTTP(w, r) + return + } + + // Create timeout ctx. + toCtx, cancelCtx := context.WithTimeout( + r.Context(), + requestTimeout, + ) + defer cancelCtx() + + // Serve the request using a shallow copy + // with the new context, without replacing + // the underlying request, since the latter + // may be used later outside of the Gin + // engine for post-request cleanup tasks. + th.Engine.ServeHTTP(w, r.WithContext(toCtx)) +} diff --git a/internal/web/web.go b/internal/web/web.go index 6d785667b..86e74d6f8 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -74,7 +74,7 @@ func New(db db.DB, processor *processing.Processor) *Module { } } -func (m *Module) Route(r router.Router, mi ...gin.HandlerFunc) { +func (m *Module) Route(r *router.Router, mi ...gin.HandlerFunc) { // Group all static files from assets dir at /assets, // so that they can use the same cache control middleware. webAssetsAbsFilePath, err := filepath.Abs(config.GetWebAssetBaseDir()) diff --git a/testrig/router.go b/testrig/router.go index 42716da29..64b3842de 100644 --- a/testrig/router.go +++ b/testrig/router.go @@ -33,7 +33,7 @@ import ( // // If the environment variable GTS_WEB_TEMPLATE_BASE_DIR set, it will take that // value as the template base directory instead. -func NewTestRouter(db db.DB) router.Router { +func NewTestRouter(db db.DB) *router.Router { if alternativeTemplateBaseDir := os.Getenv("GTS_WEB_TEMPLATE_BASE_DIR"); alternativeTemplateBaseDir != "" { config.Config(func(cfg *config.Configuration) { cfg.WebTemplateBaseDir = alternativeTemplateBaseDir