From 0cbab627c77002711029527f4697fc7ec6cd870d Mon Sep 17 00:00:00 2001 From: Tobi Smethurst <31960611+tsmethurst@users.noreply.github.com> Date: Sun, 9 May 2021 11:25:13 +0200 Subject: [PATCH] Letsencrypt (#17) --- cmd/gotosocial/main.go | 20 ++++++++ internal/config/config.go | 63 +++++++++++++++++------ internal/config/default.go | 18 +++++++ internal/config/letsencrypt.go | 11 ++++ internal/gotosocial/actions.go | 29 +++++++++++ internal/router/router.go | 93 +++++++++++++++++++++++++++++----- 6 files changed, 204 insertions(+), 30 deletions(-) create mode 100644 internal/config/letsencrypt.go diff --git a/cmd/gotosocial/main.go b/cmd/gotosocial/main.go index 337e7b785..948215276 100644 --- a/cmd/gotosocial/main.go +++ b/cmd/gotosocial/main.go @@ -228,6 +228,26 @@ func main() { Value: defaults.StatusesMaxMediaFiles, EnvVars: []string{envNames.StatusesMaxMediaFiles}, }, + + // LETSENCRYPT FLAGS + &cli.BoolFlag{ + Name: flagNames.LetsEncryptEnabled, + Usage: "Enable letsencrypt TLS certs for this server. If set to true, then cert dir also needs to be set (or take the default).", + Value: defaults.LetsEncryptEnabled, + EnvVars: []string{envNames.LetsEncryptEnabled}, + }, + &cli.StringFlag{ + Name: flagNames.LetsEncryptCertDir, + Usage: "Directory to store acquired letsencrypt certificates.", + Value: defaults.LetsEncryptCertDir, + EnvVars: []string{envNames.LetsEncryptCertDir}, + }, + &cli.StringFlag{ + Name: flagNames.LetsEncryptEmailAddress, + Usage: "Email address to use when requesting letsencrypt certs. Will receive updates on cert expiry etc.", + Value: defaults.LetsEncryptEmailAddress, + EnvVars: []string{envNames.LetsEncryptEmailAddress}, + }, }, Commands: []*cli.Command{ { diff --git a/internal/config/config.go b/internal/config/config.go index 2421290e7..23f7d0d1c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -27,16 +27,17 @@ import ( // Config pulls together all the configuration needed to run gotosocial type Config struct { - LogLevel string `yaml:"logLevel"` - ApplicationName string `yaml:"applicationName"` - Host string `yaml:"host"` - Protocol string `yaml:"protocol"` - DBConfig *DBConfig `yaml:"db"` - TemplateConfig *TemplateConfig `yaml:"template"` - AccountsConfig *AccountsConfig `yaml:"accounts"` - MediaConfig *MediaConfig `yaml:"media"` - StorageConfig *StorageConfig `yaml:"storage"` - StatusesConfig *StatusesConfig `yaml:"statuses"` + LogLevel string `yaml:"logLevel"` + ApplicationName string `yaml:"applicationName"` + Host string `yaml:"host"` + Protocol string `yaml:"protocol"` + DBConfig *DBConfig `yaml:"db"` + TemplateConfig *TemplateConfig `yaml:"template"` + AccountsConfig *AccountsConfig `yaml:"accounts"` + MediaConfig *MediaConfig `yaml:"media"` + StorageConfig *StorageConfig `yaml:"storage"` + StatusesConfig *StatusesConfig `yaml:"statuses"` + LetsEncryptConfig *LetsEncryptConfig `yaml:"letsEncrypt"` } // FromFile returns a new config from a file, or an error if something goes amiss. @@ -54,12 +55,13 @@ func FromFile(path string) (*Config, error) { // Empty just returns a new empty config func Empty() *Config { return &Config{ - DBConfig: &DBConfig{}, - TemplateConfig: &TemplateConfig{}, - AccountsConfig: &AccountsConfig{}, - MediaConfig: &MediaConfig{}, - StorageConfig: &StorageConfig{}, - StatusesConfig: &StatusesConfig{}, + DBConfig: &DBConfig{}, + TemplateConfig: &TemplateConfig{}, + AccountsConfig: &AccountsConfig{}, + MediaConfig: &MediaConfig{}, + StorageConfig: &StorageConfig{}, + StatusesConfig: &StatusesConfig{}, + LetsEncryptConfig: &LetsEncryptConfig{}, } } @@ -200,6 +202,19 @@ func (c *Config) ParseCLIFlags(f KeyedFlags) { if c.StatusesConfig.MaxMediaFiles == 0 || f.IsSet(fn.StatusesMaxMediaFiles) { c.StatusesConfig.MaxMediaFiles = f.Int(fn.StatusesMaxMediaFiles) } + + // letsencrypt flags + if f.IsSet(fn.LetsEncryptEnabled) { + c.LetsEncryptConfig.Enabled = f.Bool(fn.LetsEncryptEnabled) + } + + if c.LetsEncryptConfig.CertDir == "" || f.IsSet(fn.LetsEncryptCertDir) { + c.LetsEncryptConfig.CertDir = f.String(fn.LetsEncryptCertDir) + } + + if c.LetsEncryptConfig.EmailAddress == "" || f.IsSet(fn.LetsEncryptEmailAddress) { + c.LetsEncryptConfig.EmailAddress = f.String(fn.LetsEncryptEmailAddress) + } } // KeyedFlags is a wrapper for any type that can store keyed flags and give them back. @@ -249,6 +264,10 @@ type Flags struct { StatusesPollMaxOptions string StatusesPollOptionMaxChars string StatusesMaxMediaFiles string + + LetsEncryptEnabled string + LetsEncryptCertDir string + LetsEncryptEmailAddress string } // Defaults contains all the default values for a gotosocial config @@ -288,6 +307,10 @@ type Defaults struct { StatusesPollMaxOptions int StatusesPollOptionMaxChars int StatusesMaxMediaFiles int + + LetsEncryptEnabled bool + LetsEncryptCertDir string + LetsEncryptEmailAddress string } // GetFlagNames returns a struct containing the names of the various flags used for @@ -329,6 +352,10 @@ func GetFlagNames() Flags { StatusesPollMaxOptions: "statuses-poll-max-options", StatusesPollOptionMaxChars: "statuses-poll-option-max-chars", StatusesMaxMediaFiles: "statuses-max-media-files", + + LetsEncryptEnabled: "letsencrypt-enabled", + LetsEncryptCertDir: "letsencrypt-cert-dir", + LetsEncryptEmailAddress: "letsencrypt-email", } } @@ -371,5 +398,9 @@ func GetEnvNames() Flags { StatusesPollMaxOptions: "GTS_STATUSES_POLL_MAX_OPTIONS", StatusesPollOptionMaxChars: "GTS_STATUSES_POLL_OPTION_MAX_CHARS", StatusesMaxMediaFiles: "GTS_STATUSES_MAX_MEDIA_FILES", + + LetsEncryptEnabled: "GTS_LETSENCRYPT_ENABLED", + LetsEncryptCertDir: "GTS_LETSENCRYPT_CERT_DIR", + LetsEncryptEmailAddress: "GTS_LETSENCRYPT_EMAIL", } } diff --git a/internal/config/default.go b/internal/config/default.go index b2d82110b..f63579753 100644 --- a/internal/config/default.go +++ b/internal/config/default.go @@ -45,6 +45,11 @@ func TestDefault() *Config { PollOptionMaxChars: defaults.StatusesPollOptionMaxChars, MaxMediaFiles: defaults.StatusesMaxMediaFiles, }, + LetsEncryptConfig: &LetsEncryptConfig{ + Enabled: defaults.LetsEncryptEnabled, + CertDir: defaults.LetsEncryptCertDir, + EmailAddress: defaults.LetsEncryptEmailAddress, + }, } } @@ -93,6 +98,11 @@ func Default() *Config { PollOptionMaxChars: defaults.StatusesPollOptionMaxChars, MaxMediaFiles: defaults.StatusesMaxMediaFiles, }, + LetsEncryptConfig: &LetsEncryptConfig{ + Enabled: defaults.LetsEncryptEnabled, + CertDir: defaults.LetsEncryptCertDir, + EmailAddress: defaults.LetsEncryptEmailAddress, + }, } } @@ -135,6 +145,10 @@ func GetDefaults() Defaults { StatusesPollMaxOptions: 6, StatusesPollOptionMaxChars: 50, StatusesMaxMediaFiles: 6, + + LetsEncryptEnabled: true, + LetsEncryptCertDir: "/gotosocial/storage/certs", + LetsEncryptEmailAddress: "", } } @@ -176,5 +190,9 @@ func GetTestDefaults() Defaults { StatusesPollMaxOptions: 6, StatusesPollOptionMaxChars: 50, StatusesMaxMediaFiles: 6, + + LetsEncryptEnabled: false, + LetsEncryptCertDir: "", + LetsEncryptEmailAddress: "", } } diff --git a/internal/config/letsencrypt.go b/internal/config/letsencrypt.go new file mode 100644 index 000000000..ae40cb878 --- /dev/null +++ b/internal/config/letsencrypt.go @@ -0,0 +1,11 @@ +package config + +// LetsEncryptConfig wraps everything needed to manage letsencrypt certificates from within gotosocial. +type LetsEncryptConfig struct { + // Should letsencrypt certificate fetching be enabled? + Enabled bool + // Where should certificates be stored? + CertDir string + // Email address to pass to letsencrypt for notifications about certificate expiry etc. + EmailAddress string +} diff --git a/internal/gotosocial/actions.go b/internal/gotosocial/actions.go index 8d3142f84..0cdb29de5 100644 --- a/internal/gotosocial/actions.go +++ b/internal/gotosocial/actions.go @@ -40,6 +40,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/media" "github.com/superseriousbusiness/gotosocial/internal/message" "github.com/superseriousbusiness/gotosocial/internal/oauth" @@ -49,6 +50,28 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/typeutils" ) +var models []interface{} = []interface{}{ + >smodel.Account{}, + >smodel.Application{}, + >smodel.Block{}, + >smodel.DomainBlock{}, + >smodel.EmailDomainBlock{}, + >smodel.Follow{}, + >smodel.FollowRequest{}, + >smodel.MediaAttachment{}, + >smodel.Mention{}, + >smodel.Status{}, + >smodel.StatusFave{}, + >smodel.StatusBookmark{}, + >smodel.StatusMute{}, + >smodel.StatusPin{}, + >smodel.Tag{}, + >smodel.User{}, + >smodel.Emoji{}, + &oauth.Token{}, + &oauth.Client{}, +} + // Run creates and starts a gotosocial server var Run action.GTSAction = func(ctx context.Context, c *config.Config, log *logrus.Logger) error { dbService, err := db.NewPostgresService(ctx, c, log) @@ -109,6 +132,12 @@ var Run action.GTSAction = func(ctx context.Context, c *config.Config, log *logr } } + for _, m := range models { + if err := dbService.CreateTable(m); err != nil { + return fmt.Errorf("table creation error: %s", err) + } + } + if err := dbService.CreateInstanceAccount(); err != nil { return fmt.Errorf("error creating instance account: %s", err) } diff --git a/internal/router/router.go b/internal/router/router.go index 7ab208ef6..0f1f288bd 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -31,6 +31,7 @@ import ( "github.com/gin-gonic/gin" "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/config" + "golang.org/x/crypto/acme/autocert" ) // Router provides the REST interface for gotosocial, using gin. @@ -47,18 +48,43 @@ type Router interface { // router fulfils the Router interface using gin and logrus type router struct { - logger *logrus.Logger - engine *gin.Engine - srv *http.Server + logger *logrus.Logger + engine *gin.Engine + srv *http.Server + config *config.Config + certManager *autocert.Manager } -// Start starts the router nicely +// Start starts the router nicely. +// +// Different ports and handlers will be served depending on whether letsencrypt is enabled or not. +// If it is enabled, then port 80 will be used for handling LE requests, and port 443 will be used +// for serving actual requests. +// +// If letsencrypt is not being used, then port 8080 only will be used for serving requests. func (r *router) Start() { - go func() { - if err := r.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { - r.logger.Fatalf("listen: %s", err) - } - }() + if r.config.LetsEncryptConfig.Enabled { + // serve the http handler on port 80 for receiving letsencrypt requests and solving their devious riddles + go func() { + if err := http.ListenAndServe(":http", r.certManager.HTTPHandler(http.HandlerFunc(httpsRedirect))); err != nil && err != http.ErrServerClosed { + r.logger.Fatalf("listen: %s", err) + } + }() + + // and serve the actual TLS handler on port 443 + go func() { + if err := r.srv.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed { + r.logger.Fatalf("listen: %s", err) + } + }() + } else { + // no tls required so just serve on port 8080 + go func() { + if err := r.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + r.logger.Fatalf("listen: %s", err) + } + }() + } } // Stop shuts down the router nicely @@ -93,6 +119,8 @@ func New(config *config.Config, logger *logrus.Logger) (Router, error) { default: gin.SetMode(gin.ReleaseMode) } + + // create the actual engine here -- this is the core request routing handler for gts engine := gin.Default() // create a new session store middleware @@ -111,13 +139,40 @@ func New(config *config.Config, logger *logrus.Logger) (Router, error) { logger.Debugf("loading templates from %s", tmPath) engine.LoadHTMLGlob(tmPath) - return &router{ - logger: logger, - engine: engine, - srv: &http.Server{ + // create the actual http server here + var s *http.Server + var m *autocert.Manager + + // 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. + if config.LetsEncryptConfig.Enabled { + // le IS enabled, so roll up an autocert manager for handling letsencrypt requests + m = &autocert.Manager{ + Prompt: autocert.AcceptTOS, + HostPolicy: autocert.HostWhitelist(config.Host), + Cache: autocert.DirCache(config.LetsEncryptConfig.CertDir), + Email: config.LetsEncryptConfig.EmailAddress, + } + // and create an HTTPS server + s = &http.Server{ + Addr: ":https", + TLSConfig: m.TLSConfig(), + Handler: engine, + } + } else { + // le is NOT enabled, so just serve bare requests on port 8080 + s = &http.Server{ Addr: ":8080", Handler: engine, - }, + } + } + + return &router{ + logger: logger, + engine: engine, + srv: s, + config: config, + certManager: m, }, nil } @@ -136,3 +191,13 @@ func sessionStore() (memstore.Store, error) { return memstore.NewStore(auth, crypt), nil } + +func httpsRedirect(w http.ResponseWriter, req *http.Request) { + target := "https://" + req.Host + req.URL.Path + + if len(req.URL.RawQuery) > 0 { + target += "?" + req.URL.RawQuery + } + + http.Redirect(w, req, target, http.StatusTemporaryRedirect) +}