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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {intl.formatMessage({ id: "setup-database.title" })}
+
+
+ {intl.formatMessage({ id: "setup-database.description" })}
+
+
+
+
+ {({ isSubmitting, values, handleChange, setFieldValue }) => (
+
+ )}
+
+
+
+
+
+
+
+ );
+}
+
+export default SetupDatabase;