diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 996e754b2..9d7c2aeea 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -30,59 +30,83 @@ func main() { config.CreateDataFolders() logger.Info("Build Version: %s (%s)", version, commit) - migrations.Migrate(func() { - if err := jwt.LoadKeys(); err != nil { - logger.Error("KeysError", err) + if err := config.LoadDBConfig(); err != nil { + logger.Error("DatabaseConfigError", err) + os.Exit(1) + } + + if config.Configuration.DB.IsConfigured() { + if err := start(); err != nil { + logger.Error("StartupError", err) os.Exit(1) } + } else { + logger.Warn("No database configured, starting in Database Setup Mode") + api.SetHandler(api.NewSetupRouter(start)) + } - checkSetup() + // Http server + go api.StartServer() - // Internal Job Queue - jobqueue.Start() - certificate.AddPendingJobs() - host.AddPendingJobs() + irqchan := make(chan os.Signal, 1) + signal.Notify(irqchan, syscall.SIGINT, syscall.SIGTERM) - // Http server - api.StartServer() - irqchan := make(chan os.Signal, 1) - signal.Notify(irqchan, syscall.SIGINT, syscall.SIGTERM) - - for irq := range irqchan { - if irq == syscall.SIGINT || irq == syscall.SIGTERM { - logger.Info("Got ", irq, " shutting server down ...") - // Close db - sqlDB, _ := database.GetDB().DB() - err := sqlDB.Close() - if err != nil { - logger.Error("DatabaseCloseError", err) - } - // nolint - jobqueue.Shutdown() - break - } + for irq := range irqchan { + if irq == syscall.SIGINT || irq == syscall.SIGTERM { + logger.Info("Got %v, shutting server down ...", irq) + database.Close() + // nolint + jobqueue.Shutdown() + break } - }) + } +} + +// start connects to the configured database, applies migrations and +// starts everything that depends on the database, then switches the http +// server over to the full router. It runs at boot when a database is +// already configured, otherwise when the database setup wizard completes. +func start() error { + if !migrations.Migrate(func() {}) { + return errors.ErrDatabaseUnavailable + } + + if err := jwt.LoadKeys(); err != nil { + return err + } + + if err := checkSetup(); err != nil { + return err + } + + // Internal Job Queue + jobqueue.Start() + certificate.AddPendingJobs() + host.AddPendingJobs() + + config.IsDBSetup = true + api.SetHandler(api.NewRouter()) + return nil } // checkSetup Quick check by counting the number of users in the database -func checkSetup() { +func checkSetup() error { db := database.GetDB() - var count int64 - - if db != nil { - db.Model(&user.Model{}). - Where("is_disabled = ?", false). - Where("is_system = ?", false). - Count(&count) - - if count == 0 { - logger.Warn("No users found, starting in Setup Mode") - } else { - config.IsSetup = true - logger.Info("Application is setup") - } - } else { - logger.Error("DatabaseError", errors.ErrDatabaseUnavailable) + if db == nil { + return errors.ErrDatabaseUnavailable } + + var count int64 + db.Model(&user.Model{}). + Where("is_disabled = ?", false). + Where("is_system = ?", false). + Count(&count) + + if count == 0 { + logger.Warn("No users found, starting in Setup Mode") + } else { + config.IsSetup = true + logger.Info("Application is setup") + } + return nil } diff --git a/backend/embed/api_docs/api.swagger.json b/backend/embed/api_docs/api.swagger.json index 7946b9cb5..88963e65e 100644 --- a/backend/embed/api_docs/api.swagger.json +++ b/backend/embed/api_docs/api.swagger.json @@ -138,6 +138,11 @@ "$ref": "file://./paths/schema/get.json" } }, + "/setup/database": { + "post": { + "$ref": "file://./paths/setup/database/post.json" + } + }, "/settings": { "get": { "$ref": "file://./paths/settings/get.json" diff --git a/backend/embed/api_docs/components/HealthObject.json b/backend/embed/api_docs/components/HealthObject.json index cf2897a72..0cf772379 100644 --- a/backend/embed/api_docs/components/HealthObject.json +++ b/backend/embed/api_docs/components/HealthObject.json @@ -2,7 +2,7 @@ "type": "object", "description": "HealthObject", "additionalProperties": false, - "required": ["version", "commit", "healthy", "setup"], + "required": ["version", "commit", "healthy", "db_setup", "setup"], "properties": { "version": { "type": "string", @@ -18,6 +18,10 @@ "type": "boolean", "description": "Healthy?" }, + "db_setup": { + "type": "boolean", + "description": "Is the database configured?" + }, "setup": { "type": "boolean", "description": "Is the application set up?" diff --git a/backend/embed/api_docs/paths/get.json b/backend/embed/api_docs/paths/get.json index 567b2e78a..3c18004de 100644 --- a/backend/embed/api_docs/paths/get.json +++ b/backend/embed/api_docs/paths/get.json @@ -22,6 +22,7 @@ "version": "3.0.0", "commit": "9f119b6", "healthy": true, + "db_setup": true, "setup": true } } @@ -32,6 +33,7 @@ "version": "3.0.0", "commit": "9f119b6", "healthy": false, + "db_setup": true, "setup": true } } diff --git a/backend/embed/api_docs/paths/setup/database/post.json b/backend/embed/api_docs/paths/setup/database/post.json new file mode 100644 index 000000000..2e067ed49 --- /dev/null +++ b/backend/embed/api_docs/paths/setup/database/post.json @@ -0,0 +1,103 @@ +{ + "operationId": "setupDatabase", + "summary": "Configure the database connection. Only available before a database has been configured", + "tags": ["Setup"], + "requestBody": { + "description": "Database Connection Payload", + "required": true, + "content": { + "application/json": { + "schema": "{{schema.SetupDatabase}}" + } + } + }, + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["result"], + "properties": { + "result": { + "type": "boolean" + } + } + }, + "examples": { + "default": { + "value": { + "result": true + } + } + } + } + } + }, + "400": { + "description": "400 response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "required": ["error"], + "properties": { + "result": { + "type": "object", + "nullable": true + }, + "error": { + "$ref": "#/components/schemas/ErrorObject" + } + } + }, + "examples": { + "default": { + "value": { + "result": null, + "error": { + "code": 400, + "message": "failed to connect to `user=npm database=npm`: dial tcp 172.18.0.2:5432: connect: connection refused" + } + } + } + } + } + } + }, + "403": { + "description": "403 response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "required": ["error"], + "properties": { + "result": { + "type": "object", + "nullable": true + }, + "error": { + "$ref": "#/components/schemas/ErrorObject" + } + } + }, + "examples": { + "default": { + "value": { + "result": null, + "error": { + "code": 403, + "message": "Database is already configured" + } + } + } + } + } + } + } + } +} diff --git a/backend/internal/api/handler/health.go b/backend/internal/api/handler/health.go index 6f0ac3137..1ff94fd34 100644 --- a/backend/internal/api/handler/health.go +++ b/backend/internal/api/handler/health.go @@ -13,6 +13,7 @@ type healthCheckResponse struct { Commit string `json:"commit"` AcmeShVersion string `json:"acme.sh"` Healthy bool `json:"healthy"` + IsDBSetup bool `json:"db_setup"` IsSetup bool `json:"setup"` } @@ -24,6 +25,7 @@ func Health() func(http.ResponseWriter, *http.Request) { Version: config.Version, Commit: config.Commit, Healthy: true, + IsDBSetup: config.IsDBSetup, IsSetup: config.IsSetup, AcmeShVersion: acme.GetAcmeShVersion(), } diff --git a/backend/internal/api/handler/schema.go b/backend/internal/api/handler/schema.go index d7a3f0cf1..60cafd3b1 100644 --- a/backend/internal/api/handler/schema.go +++ b/backend/internal/api/handler/schema.go @@ -86,6 +86,8 @@ func replaceIncomingSchemas(swaggerSchema []byte) []byte { str = strings.ReplaceAll(str, `"{{schema.CreateCertificate}}"`, schema.CreateCertificate()) str = strings.ReplaceAll(str, `"{{schema.UpdateCertificate}}"`, schema.UpdateCertificate("")) + str = strings.ReplaceAll(str, `"{{schema.SetupDatabase}}"`, schema.SetupDatabase()) + str = strings.ReplaceAll(str, `"{{schema.CreateSetting}}"`, schema.CreateSetting()) str = strings.ReplaceAll(str, `"{{schema.UpdateSetting}}"`, schema.UpdateSetting()) diff --git a/backend/internal/api/handler/setup.go b/backend/internal/api/handler/setup.go new file mode 100644 index 000000000..ba3350909 --- /dev/null +++ b/backend/internal/api/handler/setup.go @@ -0,0 +1,77 @@ +package handler + +import ( + "encoding/json" + "net/http" + "sync" + + c "npm/internal/api/context" + h "npm/internal/api/http" + "npm/internal/config" + "npm/internal/database" + "npm/internal/logger" +) + +// SetupDatabase tests the connection parameters provided by the setup +// wizard, saves them to the db.conf file and then calls onConfigured to +// connect, migrate and finish starting the application. +// Route: POST /setup/database +func SetupDatabase(onConfigured func() error) func(http.ResponseWriter, *http.Request) { + // Serialises submissions so that two browsers can't race to + // configure different databases + var mu sync.Mutex + + return func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + + if config.IsDBSetup { + h.ResultErrorJSON(w, r, http.StatusForbidden, "Database is already configured", nil) + return + } + + bodyBytes, _ := r.Context().Value(c.BodyCtxKey).([]byte) + var newCfg config.DBConfig + if err := json.Unmarshal(bodyBytes, &newCfg); err != nil { + h.ResultErrorJSON(w, r, http.StatusBadRequest, h.ErrInvalidPayload.Error(), nil) + return + } + + // Sqlite lives in the data folder and needs nothing else + if newCfg.GetDriver() == config.DatabaseSqlite { + newCfg = config.DBConfig{Driver: config.DatabaseSqlite} + } else if newCfg.SSLMode == "" { + newCfg.SSLMode = "disable" + } + + if err := database.TestConnection(newCfg); err != nil { + logger.Warn("Database setup connection test failed: %s", err.Error()) + h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) + return + } + + if err := config.SaveDBConfig(newCfg); err != nil { + logger.Error("DatabaseConfigSaveError", err) + h.ResultErrorJSON(w, r, http.StatusInternalServerError, "Unable to save database configuration", nil) + return + } + + prevCfg := config.Configuration.DB + config.Configuration.DB = newCfg + logger.Info("Database configuration saved to %s", config.GetDBConfigFile()) + + if err := onConfigured(); err != nil { + logger.Error("DatabaseSetupError", err) + // Roll back so the wizard can be attempted again + database.Close() + config.Configuration.DB = prevCfg + if rmErr := config.RemoveDBConfig(); rmErr != nil { + logger.Error("DatabaseConfigRemoveError", rmErr) + } + h.ResultErrorJSON(w, r, http.StatusInternalServerError, err.Error(), nil) + return + } + + h.ResultResponseJSON(w, r, http.StatusOK, true) + } +} diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go index fd9d18ffa..00959d20b 100644 --- a/backend/internal/api/router.go +++ b/backend/internal/api/router.go @@ -5,6 +5,7 @@ import ( "time" "npm/internal/api/handler" + h "npm/internal/api/http" "npm/internal/api/middleware" "npm/internal/api/schema" "npm/internal/config" @@ -28,6 +29,37 @@ import ( // NewRouter returns a new router object func NewRouter() http.Handler { + r := newBaseRouter(middleware.DecodeAuth()) + return applyRoutes(r) +} + +// NewSetupRouter returns the router used before a database has been +// configured. It only serves the frontend, the health and schema endpoints +// and the database setup wizard endpoint. Everything else needs the database +// (including JWT keys for auth) so it is unavailable until onConfigured has +// run and the full router has replaced this one. +func NewSetupRouter(onConfigured func() error) http.Handler { + r := newBaseRouter() + r.NotFound(handler.NotFound()) + r.MethodNotAllowed(handler.NotAllowed()) + + r.With(chiMiddleware.Timeout(30*time.Second)).Route("/api", func(r chi.Router) { + r.Get("/", handler.Health()) + r.Get("/schema", handler.Schema()) + r.With(middleware.EnforceRequestSchema(schema.SetupDatabase())). + Post("/setup/database", handler.SetupDatabase(onConfigured)) + + r.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) { + h.ResultErrorJSON(w, r, http.StatusForbidden, "Not available during database setup phase", nil) + }) + }) + + return r +} + +// newBaseRouter returns a router with the middleware common to all routers. +// extra is inserted before the request body and logging middleware. +func newBaseRouter(extra ...func(http.Handler) http.Handler) *chi.Mux { // Cors corss := cors.New(cors.Options{ AllowedOrigins: []string{"*"}, @@ -60,12 +92,14 @@ func NewRouter() http.Handler { chiMiddleware.Throttle(5), middleware.PrettyPrint, middleware.Expansion, - middleware.DecodeAuth(), + ) + r.Use(extra...) + r.Use( middleware.BodyContext(), middleware.Log, ) - return applyRoutes(r) + return r } // applyRoutes is where the magic happens diff --git a/backend/internal/api/router_setup_test.go b/backend/internal/api/router_setup_test.go new file mode 100644 index 000000000..53498784d --- /dev/null +++ b/backend/internal/api/router_setup_test.go @@ -0,0 +1,79 @@ +package api + +import ( + "errors" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "npm/internal/config" + "npm/internal/database" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSetupRouter(t *testing.T) { + config.Configuration.DataFolder = t.TempDir() + config.Configuration.DB = config.DBConfig{} + config.IsDBSetup = false + defer func() { + database.Close() + config.Configuration.DB = config.DBConfig{} + config.IsDBSetup = false + }() + + post := func(sr http.Handler, body string) *httptest.ResponseRecorder { + respRec := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/api/setup/database", strings.NewReader(body)) + sr.ServeHTTP(respRec, req) + return respRec + } + + failing := NewSetupRouter(func() error { return errors.New("boom") }) + + // Health is available and reports the database isn't set up + respRec := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/api/", nil) + failing.ServeHTTP(respRec, req) + assert.Equal(t, http.StatusOK, respRec.Code) + assert.Contains(t, respRec.Body.String(), `"db_setup":false`) + + // Everything else in the api is blocked + respRec = httptest.NewRecorder() + req, _ = http.NewRequest("POST", "/api/users", strings.NewReader(`{}`)) + failing.ServeHTTP(respRec, req) + assert.Equal(t, http.StatusForbidden, respRec.Code) + + // Schema validation + assert.Equal(t, http.StatusBadRequest, post(failing, `{"driver":"oracle"}`).Code) + + // Postgres/mysql config missing fields + assert.Equal(t, http.StatusBadRequest, post(failing, `{"driver":"postgres","host":"db"}`).Code) + + // A failure to start rolls back the saved config + assert.Equal(t, http.StatusInternalServerError, post(failing, `{"driver":"sqlite"}`).Code) + assert.False(t, config.Configuration.DB.IsConfigured()) + _, err := os.Stat(config.GetDBConfigFile()) + assert.True(t, os.IsNotExist(err)) + + // Success saves the config and calls the callback + called := false + sr := NewSetupRouter(func() error { + called = true + config.IsDBSetup = true + return nil + }) + respRec = post(sr, `{"driver":"sqlite","host":"ignored"}`) + require.Equal(t, http.StatusOK, respRec.Code, respRec.Body.String()) + assert.True(t, called) + assert.Equal(t, config.DBConfig{Driver: config.DatabaseSqlite}, config.Configuration.DB) + content, err := os.ReadFile(config.GetDBConfigFile()) + require.NoError(t, err) + assert.Contains(t, string(content), `"driver": "sqlite"`) + + // Can't be configured twice + assert.Equal(t, http.StatusForbidden, post(sr, `{"driver":"sqlite"}`).Code) +} diff --git a/backend/internal/api/schema/setup_database.go b/backend/internal/api/schema/setup_database.go new file mode 100644 index 000000000..56944672d --- /dev/null +++ b/backend/internal/api/schema/setup_database.go @@ -0,0 +1,38 @@ +package schema + +import "fmt" + +// SetupDatabase is the schema for incoming data validation. +// Fields required by postgres and mysql are checked by +// config.DBConfig.IsValid once the driver is known. +func SetupDatabase() string { + stdField := stringMinMax(1, 255) + return fmt.Sprintf(` + { + "type": "object", + "additionalProperties": false, + "required": [ + "driver" + ], + "properties": { + "driver": { + "type": "string", + "enum": ["sqlite", "postgres", "mysql"] + }, + "host": %s, + "port": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + "username": %s, + "password": %s, + "name": %s, + "sslmode": { + "type": "string", + "enum": ["disable", "allow", "prefer", "require", "verify-ca", "verify-full"] + } + } + } + `, stdField, stdField, stringMinMax(0, 255), stdField) +} diff --git a/backend/internal/api/server.go b/backend/internal/api/server.go index f6593df4e..60fb97cf8 100644 --- a/backend/internal/api/server.go +++ b/backend/internal/api/server.go @@ -3,6 +3,7 @@ package api import ( "fmt" "net/http" + "sync/atomic" "time" "npm/internal/logger" @@ -11,13 +12,25 @@ import ( const httpPort = 3000 +// currentHandler is the router serving requests. It starts as the setup +// router when no database is configured and is replaced by the full router +// once the database setup wizard completes, without restarting the server. +var currentHandler atomic.Pointer[http.Handler] + +// SetHandler replaces the router serving requests +func SetHandler(handler http.Handler) { + currentHandler.Store(&handler) +} + // StartServer creates a http server func StartServer() { logger.Info("Server starting on port %v", httpPort) server := &http.Server{ - Addr: fmt.Sprintf(":%v", httpPort), - Handler: NewRouter(), + Addr: fmt.Sprintf(":%v", httpPort), + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + (*currentHandler.Load()).ServeHTTP(w, r) + }), ReadHeaderTimeout: 3 * time.Second, } diff --git a/backend/internal/config/db.go b/backend/internal/config/db.go index 382e0a636..95737d628 100644 --- a/backend/internal/config/db.go +++ b/backend/internal/config/db.go @@ -15,8 +15,11 @@ const ( DatabaseMysql = "mysql" ) -type db struct { - Driver string `json:"driver" envconfig:"optional,default=sqlite"` +// DBConfig holds the database connection parameters. It is populated from +// NPM_DB_* environment variables when NPM_DB_DRIVER is set, otherwise from +// the db.conf file in the data folder written by the setup wizard. +type DBConfig struct { + Driver string `json:"driver" envconfig:"optional"` Host string `json:"host" envconfig:"optional,default="` Port int `json:"port" envconfig:"optional,default="` Username string `json:"username" envconfig:"optional,default="` @@ -26,13 +29,19 @@ type db struct { } // GetDriver returns the lowercase driver name -func (d *db) GetDriver() string { +func (d *DBConfig) GetDriver() string { return strings.ToLower(d.Driver) } +// IsConfigured returns true when a database driver has been chosen, either +// by environment variables or by the setup wizard +func (d *DBConfig) IsConfigured() bool { + return d.Driver != "" +} + // IsValid is a basic check for config. Sqlite has no host/port/credentials // to validate; postgres and mysql require them to form a usable DSN. -func (d *db) IsValid() (bool, error) { +func (d *DBConfig) IsValid() (bool, error) { var errs []error switch d.GetDriver() { @@ -59,7 +68,7 @@ func (d *db) IsValid() (bool, error) { } // GetGormConnectURL is used by Gorm -func (d *db) GetGormConnectURL() string { +func (d *DBConfig) GetGormConnectURL() string { switch d.GetDriver() { case DatabaseSqlite: return fmt.Sprintf("%s/nginxproxymanager.db", Configuration.DataFolder) diff --git a/backend/internal/config/db_file.go b/backend/internal/config/db_file.go new file mode 100644 index 000000000..330dc7bc9 --- /dev/null +++ b/backend/internal/config/db_file.go @@ -0,0 +1,96 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" + + "npm/internal/logger" + + "github.com/rotisserie/eris" +) + +const dbConfigFilename = "db.conf" + +// GetDBConfigFile returns the path of the file the database +// connection parameters are persisted to +func GetDBConfigFile() string { + return filepath.Join(Configuration.DataFolder, dbConfigFilename) +} + +// LoadDBConfig populates the database configuration from the db.conf file, +// unless NPM_DB_DRIVER has been provided in which case the environment +// variables take precedence and the file is ignored. A missing file is not +// an error; the database simply remains unconfigured and the setup wizard +// will ask for it. +func LoadDBConfig() error { + if Configuration.DB.IsConfigured() { + logger.Info("Using database configuration from environment variables") + return nil + } + + file := GetDBConfigFile() + // nolint: gosec + content, err := os.ReadFile(file) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return eris.Wrapf(err, "unable to read %s", file) + } + + cfg := Configuration.DB + if err := json.Unmarshal(content, &cfg); err != nil { + return eris.Wrapf(err, "unable to parse %s", file) + } + + if !cfg.IsConfigured() { + return eris.Errorf("%s does not specify a database driver", file) + } + + Configuration.DB = cfg + logger.Info("Using database configuration from %s", file) + return nil +} + +// SaveDBConfig writes the database connection parameters to the db.conf +// file. The file contains credentials so it is only readable by the owner. +// It is written to a temporary file first and renamed so that a crash +// can never leave a half written config behind. +func SaveDBConfig(cfg DBConfig) error { + // Persisting the password is the point of this file + // nolint: gosec + content, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + + file := GetDBConfigFile() + tmp, err := os.CreateTemp(filepath.Dir(file), dbConfigFilename+".*") + if err != nil { + return eris.Wrapf(err, "unable to write %s", file) + } + // nolint: errcheck + defer os.Remove(tmp.Name()) + + if _, err := tmp.Write(content); err != nil { + // nolint: errcheck, gosec + tmp.Close() + return eris.Wrapf(err, "unable to write %s", file) + } + if err := tmp.Close(); err != nil { + return eris.Wrapf(err, "unable to write %s", file) + } + + return os.Rename(tmp.Name(), file) +} + +// RemoveDBConfig deletes the db.conf file, used to roll back a +// database setup that could not be completed +func RemoveDBConfig() error { + err := os.Remove(GetDBConfigFile()) + if os.IsNotExist(err) { + return nil + } + return err +} diff --git a/backend/internal/config/db_file_test.go b/backend/internal/config/db_file_test.go new file mode 100644 index 000000000..be39cf787 --- /dev/null +++ b/backend/internal/config/db_file_test.go @@ -0,0 +1,74 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/goleak" +) + +func TestDBConfigFile(t *testing.T) { + // goleak is used to detect goroutine leaks + defer goleak.VerifyNone(t, goleak.IgnoreAnyFunction("database/sql.(*DB).connectionOpener")) + + Configuration.DataFolder = t.TempDir() + Configuration.DB = DBConfig{SSLMode: "disable"} + + // No file, no env: stays unconfigured + require.NoError(t, LoadDBConfig()) + assert.False(t, Configuration.DB.IsConfigured()) + + cfg := DBConfig{ + Driver: DatabasePostgres, + Host: "db", + Port: 5432, + Username: "npm", + Password: "secret", + Name: "npm", + SSLMode: "require", + } + require.NoError(t, SaveDBConfig(cfg)) + + stat, err := os.Stat(filepath.Join(Configuration.DataFolder, "db.conf")) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0600), stat.Mode().Perm()) + + require.NoError(t, LoadDBConfig()) + assert.Equal(t, cfg, Configuration.DB) + + require.NoError(t, RemoveDBConfig()) + require.NoError(t, RemoveDBConfig()) + _, err = os.Stat(GetDBConfigFile()) + assert.True(t, os.IsNotExist(err)) +} + +func TestDBConfigFileEnvPrecedence(t *testing.T) { + // goleak is used to detect goroutine leaks + defer goleak.VerifyNone(t, goleak.IgnoreAnyFunction("database/sql.(*DB).connectionOpener")) + + Configuration.DataFolder = t.TempDir() + require.NoError(t, SaveDBConfig(DBConfig{Driver: DatabaseMysql})) + + Configuration.DB = DBConfig{Driver: DatabaseSqlite} + require.NoError(t, LoadDBConfig()) + assert.Equal(t, DatabaseSqlite, Configuration.DB.Driver) +} + +func TestDBConfigFileInvalid(t *testing.T) { + // goleak is used to detect goroutine leaks + defer goleak.VerifyNone(t, goleak.IgnoreAnyFunction("database/sql.(*DB).connectionOpener")) + + Configuration.DataFolder = t.TempDir() + + Configuration.DB = DBConfig{} + require.NoError(t, os.WriteFile(GetDBConfigFile(), []byte("not json"), 0600)) + assert.Error(t, LoadDBConfig()) + + Configuration.DB = DBConfig{} + require.NoError(t, os.WriteFile(GetDBConfigFile(), []byte(`{"host":"db"}`), 0600)) + assert.Error(t, LoadDBConfig()) + assert.False(t, Configuration.DB.IsConfigured()) +} diff --git a/backend/internal/config/vars.go b/backend/internal/config/vars.go index 8d243ecb6..c989d53e7 100644 --- a/backend/internal/config/vars.go +++ b/backend/internal/config/vars.go @@ -12,6 +12,10 @@ var Version string // Commit is the git commit set by ldflags var Commit string +// IsDBSetup defines whether the database has been configured, connected +// to and migrated +var IsDBSetup bool + // IsSetup defines whether we have an admin user or not var IsSetup bool @@ -19,12 +23,12 @@ var logLevel logger.Level // Configuration is the main configuration object var Configuration struct { - DataFolder string `json:"data_folder" envconfig:"optional,default=/data"` - DisableIPV4 bool `json:"disable_ipv4" envconfig:"optional"` - DisableIPV6 bool `json:"disable_ipv6" envconfig:"optional"` - Acmesh acmesh `json:"acmesh"` - DB db `json:"db"` - Log log `json:"log"` + DataFolder string `json:"data_folder" envconfig:"optional,default=/data"` + DisableIPV4 bool `json:"disable_ipv4" envconfig:"optional"` + DisableIPV6 bool `json:"disable_ipv6" envconfig:"optional"` + Acmesh acmesh `json:"acmesh"` + DB DBConfig `json:"db"` + Log log `json:"log"` } type log struct { diff --git a/backend/internal/database/db.go b/backend/internal/database/db.go index 6c72f8377..856132b41 100644 --- a/backend/internal/database/db.go +++ b/backend/internal/database/db.go @@ -20,13 +20,18 @@ var dbInstance *gorm.DB // NewDB creates a new connection func NewDB() { + if !config.Configuration.DB.IsConfigured() { + // The setup wizard hasn't been completed yet + return + } + if ok, err := config.Configuration.DB.IsValid(); !ok { - logger.Error("DatabaseError", eris.Wrap(err, "database configuration is invalid, check for missing environment variables")) + logger.Error("DatabaseError", eris.Wrapf(err, "database configuration is invalid, check %s or NPM_DB_* environment variables", config.GetDBConfigFile())) return } logger.Info("Creating new DB instance using %s", strings.ToLower(config.Configuration.DB.Driver)) - db, err := connect() + db, err := connect(&config.Configuration.DB) if err != nil { logger.Error("DatabaseConnectError", err) } else if db != nil { @@ -42,17 +47,52 @@ func GetDB() *gorm.DB { return dbInstance } +// TestConnection opens a connection using the given config and pings the +// server, without affecting the current instance. Used by the setup wizard +// to validate parameters before they are saved. +func TestConnection(cfg config.DBConfig) error { + if ok, err := cfg.IsValid(); !ok { + return err + } + + db, err := connect(&cfg) + if err != nil { + return err + } + + sqlDB, err := db.DB() + if err != nil { + return err + } + // nolint: errcheck + defer sqlDB.Close() + + return sqlDB.Ping() +} + +// Close closes and discards the current instance, if any +func Close() { + if dbInstance == nil { + return + } + if sqlDB, err := dbInstance.DB(); err == nil { + // nolint: errcheck, gosec + sqlDB.Close() + } + dbInstance = nil +} + // SetDB will set the dbInstance to this // Used by unit testing to set the db to a mock database func SetDB(db *gorm.DB) { dbInstance = db } -func connect() (*gorm.DB, error) { +func connect(dbCfg *config.DBConfig) (*gorm.DB, error) { var d gorm.Dialector - dsn := config.Configuration.DB.GetGormConnectURL() + dsn := dbCfg.GetGormConnectURL() - switch strings.ToLower(config.Configuration.DB.Driver) { + switch dbCfg.GetDriver() { case config.DatabaseSqlite: // autocreate(dsn) d = sqlite.Open(dsn) @@ -64,7 +104,7 @@ func connect() (*gorm.DB, error) { d = mysql.Open(dsn) default: - return nil, eris.New(fmt.Sprintf("Database driver %s is not supported. Valid options are: %s, %s or %s", config.Configuration.DB.Driver, config.DatabaseSqlite, config.DatabasePostgres, config.DatabaseMysql)) + return nil, eris.New(fmt.Sprintf("Database driver %s is not supported. Valid options are: %s, %s or %s", dbCfg.Driver, config.DatabaseSqlite, config.DatabasePostgres, config.DatabaseMysql)) } // see: https://gorm.io/docs/gorm_config.html diff --git a/frontend/src/Router.tsx b/frontend/src/Router.tsx index 5d3616ba7..5081a610e 100644 --- a/frontend/src/Router.tsx +++ b/frontend/src/Router.tsx @@ -20,6 +20,7 @@ const NginxTemplates = lazy(() => import("src/pages/NginxTemplates")); const Login = lazy(() => import("src/pages/Login")); const GeneralSettings = lazy(() => import("src/pages/Settings")); const Setup = lazy(() => import("src/pages/Setup")); +const SetupDatabase = lazy(() => import("src/pages/SetupDatabase")); const Upstreams = lazy(() => import("src/pages/Upstreams")); const Users = lazy(() => import("src/pages/Users")); @@ -48,6 +49,14 @@ function Router() { return ; } + if (health.data?.healthy && !health.data?.dbSetup) { + return ( + + + + ); + } + if (health.data?.healthy && !health.data?.setup) { return ( diff --git a/frontend/src/api/npm/index.ts b/frontend/src/api/npm/index.ts index d554c0038..256384bf8 100644 --- a/frontend/src/api/npm/index.ts +++ b/frontend/src/api/npm/index.ts @@ -31,3 +31,4 @@ export * from "./setCertificate"; export * from "./setCertificateAuthority"; export * from "./setDNSProvider"; export * from "./setUser"; +export * from "./setupDatabase"; diff --git a/frontend/src/api/npm/responseTypes.ts b/frontend/src/api/npm/responseTypes.ts index 0135a12b6..5e88aefa7 100644 --- a/frontend/src/api/npm/responseTypes.ts +++ b/frontend/src/api/npm/responseTypes.ts @@ -20,6 +20,7 @@ export interface BaseResponse { export interface HealthResponse { commit: string; + dbSetup: boolean; errorReporting: boolean; healthy: boolean; setup: boolean; diff --git a/frontend/src/api/npm/setupDatabase.ts b/frontend/src/api/npm/setupDatabase.ts new file mode 100644 index 000000000..fc8f8d7d9 --- /dev/null +++ b/frontend/src/api/npm/setupDatabase.ts @@ -0,0 +1,25 @@ +import * as api from "./base"; + +export interface DatabaseConfig { + driver: string; + host?: string; + port?: number; + username?: string; + password?: string; + name?: string; + sslmode?: string; +} + +export async function setupDatabase( + data: DatabaseConfig, + abortController?: AbortController, +): Promise { + const { result } = await api.post( + { + url: "/setup/database", + data, + }, + abortController, + ); + return result; +} diff --git a/frontend/src/locale/src/de.json b/frontend/src/locale/src/de.json index 319c93ac9..c7e87bc2b 100644 --- a/frontend/src/locale/src/de.json +++ b/frontend/src/locale/src/de.json @@ -341,6 +341,42 @@ "settings.title": { "defaultMessage": "Einstellungen" }, + "setup-database.description": { + "defaultMessage": "Wählen Sie, wo Nginx Proxy Manager seine Daten speichern soll. Diese Einstellungen werden in db.conf im Datenordner gespeichert." + }, + "setup-database.driver": { + "defaultMessage": "Datenbanktyp" + }, + "setup-database.error": { + "defaultMessage": "Verbindung zur Datenbank nicht möglich" + }, + "setup-database.host": { + "defaultMessage": "Host" + }, + "setup-database.name": { + "defaultMessage": "Datenbankname" + }, + "setup-database.password": { + "defaultMessage": "Passwort" + }, + "setup-database.port": { + "defaultMessage": "Port" + }, + "setup-database.sqlite-description": { + "defaultMessage": "SQLite speichert alles in einer einzigen Datei im Datenordner und benötigt keine weitere Konfiguration." + }, + "setup-database.sslmode": { + "defaultMessage": "SSL-Modus" + }, + "setup-database.submit": { + "defaultMessage": "Weiter" + }, + "setup-database.title": { + "defaultMessage": "Datenbank konfigurieren" + }, + "setup-database.username": { + "defaultMessage": "Benutzername" + }, "setup.create": { "defaultMessage": "Registrieren" }, diff --git a/frontend/src/locale/src/en.json b/frontend/src/locale/src/en.json index 00573e085..5e852e312 100644 --- a/frontend/src/locale/src/en.json +++ b/frontend/src/locale/src/en.json @@ -617,6 +617,42 @@ "settings.title": { "defaultMessage": "Settings" }, + "setup-database.description": { + "defaultMessage": "Choose where Nginx Proxy Manager should store its data. These settings will be saved to db.conf in the data folder." + }, + "setup-database.driver": { + "defaultMessage": "Database Type" + }, + "setup-database.error": { + "defaultMessage": "Unable to connect to the database" + }, + "setup-database.host": { + "defaultMessage": "Host" + }, + "setup-database.name": { + "defaultMessage": "Database Name" + }, + "setup-database.password": { + "defaultMessage": "Password" + }, + "setup-database.port": { + "defaultMessage": "Port" + }, + "setup-database.sqlite-description": { + "defaultMessage": "SQLite stores everything in a single file in the data folder and requires no further configuration." + }, + "setup-database.sslmode": { + "defaultMessage": "SSL Mode" + }, + "setup-database.submit": { + "defaultMessage": "Continue" + }, + "setup-database.title": { + "defaultMessage": "Configure your Database" + }, + "setup-database.username": { + "defaultMessage": "Username" + }, "setup.create": { "defaultMessage": "Sign up" }, diff --git a/frontend/src/locale/src/fa.json b/frontend/src/locale/src/fa.json index 8f546c109..481393e1b 100644 --- a/frontend/src/locale/src/fa.json +++ b/frontend/src/locale/src/fa.json @@ -341,6 +341,42 @@ "settings.title": { "defaultMessage": "تنظیمات" }, + "setup-database.description": { + "defaultMessage": "محل ذخیره داده‌های Nginx Proxy Manager را انتخاب کنید. این تنظیمات در فایل db.conf در پوشه داده ذخیره می‌شود." + }, + "setup-database.driver": { + "defaultMessage": "نوع پایگاه داده" + }, + "setup-database.error": { + "defaultMessage": "اتصال به پایگاه داده ممکن نیست" + }, + "setup-database.host": { + "defaultMessage": "میزبان" + }, + "setup-database.name": { + "defaultMessage": "نام پایگاه داده" + }, + "setup-database.password": { + "defaultMessage": "رمز عبور" + }, + "setup-database.port": { + "defaultMessage": "پورت" + }, + "setup-database.sqlite-description": { + "defaultMessage": "SQLite همه چیز را در یک فایل در پوشه داده ذخیره می‌کند و به پیکربندی بیشتری نیاز ندارد." + }, + "setup-database.sslmode": { + "defaultMessage": "حالت SSL" + }, + "setup-database.submit": { + "defaultMessage": "ادامه" + }, + "setup-database.title": { + "defaultMessage": "پیکربندی پایگاه داده" + }, + "setup-database.username": { + "defaultMessage": "نام کاربری" + }, "setup.create": { "defaultMessage": "ثبت نام" }, diff --git a/frontend/src/pages/SetupDatabase/index.tsx b/frontend/src/pages/SetupDatabase/index.tsx new file mode 100644 index 000000000..c84ca4306 --- /dev/null +++ b/frontend/src/pages/SetupDatabase/index.tsx @@ -0,0 +1,317 @@ +import { + Box, + Center, + Flex, + FormControl, + FormErrorMessage, + FormLabel, + Heading, + Input, + Select, + Stack, + Text, + useColorModeValue, + useToast, +} from "@chakra-ui/react"; +import { useQueryClient } from "@tanstack/react-query"; +import { Field, Form, Formik } from "formik"; + +import { DatabaseConfig, setupDatabase } from "src/api/npm"; +import { LocalePicker, PrettyButton, ThemeSwitcher } from "src/components"; +import { intl } from "src/locale"; +import { validateNumber, validateString } from "src/modules/Validations"; + +interface Payload { + driver: string; + host: string; + port: string; + username: string; + password: string; + name: string; + sslmode: string; +} + +const defaultPorts: Record = { + postgres: "5432", + mysql: "3306", +}; + +const sslModes = [ + "disable", + "allow", + "prefer", + "require", + "verify-ca", + "verify-full", +]; + +function SetupDatabase() { + const toast = useToast(); + const queryClient = useQueryClient(); + const mutedColor = useColorModeValue("gray.600", "gray.400"); + + const onSubmit = async (values: Payload, { setSubmitting }: any) => { + const payload: DatabaseConfig = + values.driver === "sqlite" + ? { driver: values.driver } + : { + driver: values.driver, + host: values.host, + port: +values.port, + username: values.username, + password: values.password, + name: values.name, + ...(values.driver === "postgres" + ? { sslmode: values.sslmode } + : {}), + }; + + try { + await setupDatabase(payload); + // Trigger a Health change, which moves on to the user setup step + await queryClient.refetchQueries({ queryKey: ["health"] }); + } catch (err: any) { + toast({ + title: intl.formatMessage({ id: "setup-database.error" }), + description: err.message, + status: "error", + position: "top", + duration: 10000, + isClosable: true, + }); + } + setSubmitting(false); + }; + + return ( + + + + + + + + + +
+ Logo +
+
+ + + + {intl.formatMessage({ id: "setup-database.title" })} + + + {intl.formatMessage({ id: "setup-database.description" })} + + + + + {({ isSubmitting, values, handleChange, setFieldValue }) => ( +
+ + + {({ field }: any) => ( + + + {intl.formatMessage({ + id: "setup-database.driver", + })} + + + + )} + + {values.driver === "sqlite" ? ( + + {intl.formatMessage({ + id: "setup-database.sqlite-description", + })} + + ) : ( + <> + + {({ field, form }: any) => ( + + + {intl.formatMessage({ + id: "setup-database.host", + })} + + + + {form.errors.host} + + + )} + + + {({ field, form }: any) => ( + + + {intl.formatMessage({ + id: "setup-database.port", + })} + + + + {form.errors.port} + + + )} + + + {({ field, form }: any) => ( + + + {intl.formatMessage({ + id: "setup-database.name", + })} + + + + {form.errors.name} + + + )} + + + {({ field, form }: any) => ( + + + {intl.formatMessage({ + id: "setup-database.username", + })} + + + + {form.errors.username} + + + )} + + + {({ field }: any) => ( + + + {intl.formatMessage({ + id: "setup-database.password", + })} + + + + )} + + {values.driver === "postgres" ? ( + + {({ field }: any) => ( + + + {intl.formatMessage({ + id: "setup-database.sslmode", + })} + + + + )} + + ) : null} + + )} + + + {intl.formatMessage({ id: "setup-database.submit" })} + +
+ )} +
+
+
+
+
+ +
+ ); +} + +export default SetupDatabase;