Dabase setup widget

This commit is contained in:
Jamie Curnow committed 2026-09-28 13:03:10 +10:00
1 parent 853ef909a7
commit 741c9c982f
25 files changed
+1133 -66

No files matched your search

+68 -44
View File
@@ -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
}
+5
View File
@@ -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"
@@ -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?"
+2
View File
@@ -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
}
}
@@ -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"
}
}
}
}
}
}
}
}
}
+2
View File
@@ -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(),
}
+2
View File
@@ -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())
+77
View File
@@ -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)
}
}
+36 -2
View File
@@ -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
+79
View File
@@ -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)
}
@@ -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)
}
+15 -2
View File
@@ -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,
}
+14 -5
View File
@@ -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)
+96
View File
@@ -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
}
+74
View File
@@ -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())
}
+10 -6
View File
@@ -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 {
+46 -6
View File
@@ -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
+9
View File
@@ -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 <Unhealthy />;
}
if (health.data?.healthy && !health.data?.dbSetup) {
return (
<Suspense fallback={Spinner}>
<SetupDatabase />
</Suspense>
);
}
if (health.data?.healthy && !health.data?.setup) {
return (
<Suspense fallback={Spinner}>
+1
View File
@@ -31,3 +31,4 @@ export * from "./setCertificate";
export * from "./setCertificateAuthority";
export * from "./setDNSProvider";
export * from "./setUser";
export * from "./setupDatabase";
+1
View File
@@ -20,6 +20,7 @@ export interface BaseResponse {
export interface HealthResponse {
commit: string;
dbSetup: boolean;
errorReporting: boolean;
healthy: boolean;
setup: boolean;
+25
View File
@@ -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<boolean> {
const { result } = await api.post(
{
url: "/setup/database",
data,
},
abortController,
);
return result;
}
+36
View File
@@ -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"
},
+36
View File
@@ -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"
},
+36
View File
@@ -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": "ثبت نام"
},
+317
View File
@@ -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<string, string> = {
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 (
<Flex
minH="100vh"
w="100vw"
flexDir="column"
bg={useColorModeValue("gray.50", "gray.800")}>
<Stack h={10} m={4} justify="end" direction="row">
<ThemeSwitcher />
<LocalePicker className="text-right" />
</Stack>
<Flex align="center" justify="center" flex="1">
<Stack spacing={8} mx="auto" maxW="md" w="full" py={4} px={6}>
<Box>
<Center>
<img src="/images/logo-256.png" width={100} alt="Logo" />
</Center>
</Box>
<Box
rounded="lg"
bg={useColorModeValue("white", "gray.700")}
boxShadow="lg"
p={8}>
<Stack spacing={4}>
<Heading
color={useColorModeValue("gray.800", "gray.300")}
lineHeight={1.1}
fontSize={{ base: "1xl", sm: "2xl", md: "3xl" }}>
{intl.formatMessage({ id: "setup-database.title" })}
</Heading>
<Text color={mutedColor}>
{intl.formatMessage({ id: "setup-database.description" })}
</Text>
</Stack>
<Box mt={10}>
<Formik
initialValues={
{
driver: "sqlite",
host: "",
port: "",
username: "",
password: "",
name: "",
sslmode: "disable",
} as Payload
}
onSubmit={onSubmit}>
{({ isSubmitting, values, handleChange, setFieldValue }) => (
<Form>
<Stack spacing={4}>
<Field name="driver">
{({ field }: any) => (
<FormControl isRequired>
<FormLabel htmlFor="driver">
{intl.formatMessage({
id: "setup-database.driver",
})}
</FormLabel>
<Select
{...field}
id="driver"
onChange={(e: any) => {
handleChange(e);
// Swap the port along with the driver unless it's been customised
if (
values.port === "" ||
Object.values(defaultPorts).includes(
values.port,
)
) {
setFieldValue(
"port",
defaultPorts[e.target.value] || "",
);
}
}}>
<option value="sqlite">SQLite</option>
<option value="postgres">PostgreSQL</option>
<option value="mysql">MySQL / MariaDB</option>
</Select>
</FormControl>
)}
</Field>
{values.driver === "sqlite" ? (
<Text fontSize="sm" color={mutedColor}>
{intl.formatMessage({
id: "setup-database.sqlite-description",
})}
</Text>
) : (
<>
<Field name="host" validate={validateString(1, 255)}>
{({ field, form }: any) => (
<FormControl
isRequired
isInvalid={
form.errors.host && form.touched.host
}>
<FormLabel htmlFor="host">
{intl.formatMessage({
id: "setup-database.host",
})}
</FormLabel>
<Input {...field} id="host" autoFocus />
<FormErrorMessage>
{form.errors.host}
</FormErrorMessage>
</FormControl>
)}
</Field>
<Field
name="port"
validate={validateNumber(1, 65535)}>
{({ field, form }: any) => (
<FormControl
isRequired
isInvalid={
form.errors.port && form.touched.port
}>
<FormLabel htmlFor="port">
{intl.formatMessage({
id: "setup-database.port",
})}
</FormLabel>
<Input {...field} id="port" type="number" />
<FormErrorMessage>
{form.errors.port}
</FormErrorMessage>
</FormControl>
)}
</Field>
<Field name="name" validate={validateString(1, 255)}>
{({ field, form }: any) => (
<FormControl
isRequired
isInvalid={
form.errors.name && form.touched.name
}>
<FormLabel htmlFor="name">
{intl.formatMessage({
id: "setup-database.name",
})}
</FormLabel>
<Input {...field} id="name" />
<FormErrorMessage>
{form.errors.name}
</FormErrorMessage>
</FormControl>
)}
</Field>
<Field
name="username"
validate={validateString(1, 255)}>
{({ field, form }: any) => (
<FormControl
isRequired
isInvalid={
form.errors.username && form.touched.username
}>
<FormLabel htmlFor="username">
{intl.formatMessage({
id: "setup-database.username",
})}
</FormLabel>
<Input
{...field}
id="username"
autoComplete="off"
/>
<FormErrorMessage>
{form.errors.username}
</FormErrorMessage>
</FormControl>
)}
</Field>
<Field name="password">
{({ field }: any) => (
<FormControl>
<FormLabel htmlFor="password">
{intl.formatMessage({
id: "setup-database.password",
})}
</FormLabel>
<Input
{...field}
id="password"
type="password"
autoComplete="new-password"
/>
</FormControl>
)}
</Field>
{values.driver === "postgres" ? (
<Field name="sslmode">
{({ field }: any) => (
<FormControl isRequired>
<FormLabel htmlFor="sslmode">
{intl.formatMessage({
id: "setup-database.sslmode",
})}
</FormLabel>
<Select {...field} id="sslmode">
{sslModes.map((mode) => (
<option key={mode} value={mode}>
{mode}
</option>
))}
</Select>
</FormControl>
)}
</Field>
) : null}
</>
)}
</Stack>
<PrettyButton isLoading={isSubmitting} mt={8} w="full">
{intl.formatMessage({ id: "setup-database.submit" })}
</PrettyButton>
</Form>
)}
</Formik>
</Box>
</Box>
</Stack>
</Flex>
<Box h={10} m={4} />
</Flex>
);
}
export default SetupDatabase;