Fix MySQL DSN escaping for setup passwords and clarify DB user setup

This commit is contained in:
Mikhail Chusavitin
2026-02-06 13:27:57 +03:00
parent a90c07c879
commit a1d21927a3
4 changed files with 84 additions and 26 deletions

View File

@@ -113,30 +113,52 @@ go run ./cmd/migrate_ops_projects -config config.yaml -apply -yes
Если нужен пользователь, который может работать с конфигурациями, но не может создавать/удалять прайслисты: Если нужен пользователь, который может работать с конфигурациями, но не может создавать/удалять прайслисты:
```sql ```sql
-- 1) Создать (или оставить существующего) пользователя -- 1) Создать пользователя (если его ещё нет)
CREATE USER IF NOT EXISTS 'quote_user'@'%' IDENTIFIED BY 'StrongPassword!'; CREATE USER IF NOT EXISTS 'quote_user'@'%' IDENTIFIED BY 'StrongPassword!';
-- 2) Сбросить лишние права (без пересоздания пользователя) -- 2) Если пользователь уже существовал, принудительно обновить пароль
ALTER USER 'quote_user'@'%' IDENTIFIED BY 'StrongPassword!';
-- 3) (Опционально, но рекомендуется) удалить дубли пользователя с другими host,
-- чтобы не возникало конфликтов вида user@localhost vs user@'%'
DROP USER IF EXISTS 'quote_user'@'localhost';
DROP USER IF EXISTS 'quote_user'@'127.0.0.1';
DROP USER IF EXISTS 'quote_user'@'::1';
-- 4) Сбросить лишние права
REVOKE ALL PRIVILEGES, GRANT OPTION FROM 'quote_user'@'%'; REVOKE ALL PRIVILEGES, GRANT OPTION FROM 'quote_user'@'%';
-- 3) Чтение данных для конфигуратора и синка -- 5) Чтение данных для конфигуратора и синка
GRANT SELECT ON RFQ_LOG.lot TO 'quote_user'@'%'; GRANT SELECT ON RFQ_LOG.lot TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO 'quote_user'@'%'; GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_categories TO 'quote_user'@'%'; GRANT SELECT ON RFQ_LOG.qt_categories TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_pricelists TO 'quote_user'@'%'; GRANT SELECT ON RFQ_LOG.qt_pricelists TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO 'quote_user'@'%'; GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO 'quote_user'@'%';
-- 4) Работа с конфигурациями -- 6) Работа с конфигурациями
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_configurations TO 'quote_user'@'%'; GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_configurations TO 'quote_user'@'%';
FLUSH PRIVILEGES; FLUSH PRIVILEGES;
SHOW GRANTS FOR 'quote_user'@'%'; SHOW GRANTS FOR 'quote_user'@'%';
SHOW CREATE USER 'quote_user'@'%';
```
Полный набор прав для пользователя квотаций:
```sql
GRANT USAGE ON *.* TO 'quote_user'@'%' IDENTIFIED BY 'StrongPassword!';
GRANT SELECT ON RFQ_LOG.lot TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_categories TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_pricelists TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO 'quote_user'@'%';
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_configurations TO 'quote_user'@'%';
``` ```
Важно: Важно:
- не выдавайте `INSERT/UPDATE/DELETE` на `qt_pricelists` и `qt_pricelist_items`, если пользователь не должен управлять прайслистами; - не выдавайте `INSERT/UPDATE/DELETE` на `qt_pricelists` и `qt_pricelist_items`, если пользователь не должен управлять прайслистами;
- если используется host-специфичный аккаунт (`'quote_user'@'192.168.x.x'`), назначьте права и для него; - если видите ошибку `Access denied for user ...@'<ip>'`, проверьте, что не осталось других записей `quote_user@host` кроме `quote_user@'%'`;
- после смены DB-настроек через `/setup` приложение перезапускается автоматически и подхватывает нового пользователя. - после смены DB-настроек через `/setup` приложение перезапускается автоматически и подхватывает нового пользователя.
### 4. Импорт метаданных компонентов ### 4. Импорт метаданных компонентов

View File

@@ -2,9 +2,12 @@ package config
import ( import (
"fmt" "fmt"
"net"
"os" "os"
"strconv"
"time" "time"
mysqlDriver "github.com/go-sql-driver/mysql"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
@@ -39,8 +42,18 @@ type DatabaseConfig struct {
} }
func (d *DatabaseConfig) DSN() string { func (d *DatabaseConfig) DSN() string {
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local", cfg := mysqlDriver.NewConfig()
d.User, d.Password, d.Host, d.Port, d.Name) cfg.User = d.User
cfg.Passwd = d.Password
cfg.Net = "tcp"
cfg.Addr = net.JoinHostPort(d.Host, strconv.Itoa(d.Port))
cfg.DBName = d.Name
cfg.ParseTime = true
cfg.Loc = time.Local
cfg.Params = map[string]string{
"charset": "utf8mb4",
}
return cfg.FormatDSN()
} }
type AuthConfig struct { type AuthConfig struct {

View File

@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"html/template" "html/template"
"log/slog" "log/slog"
"net"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
@@ -13,8 +14,9 @@ import (
qfassets "git.mchus.pro/mchus/quoteforge" qfassets "git.mchus.pro/mchus/quoteforge"
"git.mchus.pro/mchus/quoteforge/internal/db" "git.mchus.pro/mchus/quoteforge/internal/db"
"git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/localdb"
mysqlDriver "github.com/go-sql-driver/mysql"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/driver/mysql" gormmysql "gorm.io/driver/mysql"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/logger" "gorm.io/gorm/logger"
) )
@@ -93,10 +95,9 @@ func (h *SetupHandler) TestConnection(c *gin.Context) {
} }
} }
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=5s", dsn := buildMySQLDSN(host, port, database, user, password, 5*time.Second)
user, password, host, port, database)
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{ db, err := gorm.Open(gormmysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent), Logger: logger.Default.LogMode(logger.Silent),
}) })
if err != nil { if err != nil {
@@ -169,10 +170,9 @@ func (h *SetupHandler) SaveConnection(c *gin.Context) {
} }
// Test connection first // Test connection first
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=5s", dsn := buildMySQLDSN(host, port, database, user, password, 5*time.Second)
user, password, host, port, database)
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{ db, err := gorm.Open(gormmysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent), Logger: logger.Default.LogMode(logger.Silent),
}) })
if err != nil { if err != nil {
@@ -254,3 +254,19 @@ func testWritePermission(db *gorm.DB) bool {
return true return true
} }
func buildMySQLDSN(host string, port int, database, user, password string, timeout time.Duration) string {
cfg := mysqlDriver.NewConfig()
cfg.User = user
cfg.Passwd = password
cfg.Net = "tcp"
cfg.Addr = net.JoinHostPort(host, strconv.Itoa(port))
cfg.DBName = database
cfg.ParseTime = true
cfg.Loc = time.Local
cfg.Timeout = timeout
cfg.Params = map[string]string{
"charset": "utf8mb4",
}
return cfg.FormatDSN()
}

View File

@@ -4,12 +4,15 @@ import (
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"net"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"time" "time"
"git.mchus.pro/mchus/quoteforge/internal/appmeta" "git.mchus.pro/mchus/quoteforge/internal/appmeta"
mysqlDriver "github.com/go-sql-driver/mysql"
"github.com/glebarez/sqlite" "github.com/glebarez/sqlite"
uuidpkg "github.com/google/uuid" uuidpkg "github.com/google/uuid"
"gorm.io/gorm" "gorm.io/gorm"
@@ -141,19 +144,23 @@ func (l *LocalDB) GetDSN() (string, error) {
return "", err return "", err
} }
// Add aggressive timeouts for offline-first architecture cfg := mysqlDriver.NewConfig()
// timeout: connection establishment timeout (3s) cfg.User = settings.User
// readTimeout: I/O read timeout (3s) cfg.Passwd = settings.PasswordEncrypted // Contains decrypted password after GetSettings
// writeTimeout: I/O write timeout (3s) cfg.Net = "tcp"
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=3s&readTimeout=3s&writeTimeout=3s", cfg.Addr = net.JoinHostPort(settings.Host, strconv.Itoa(settings.Port))
settings.User, cfg.DBName = settings.Database
settings.PasswordEncrypted, // Contains decrypted password after GetSettings cfg.ParseTime = true
settings.Host, cfg.Loc = time.Local
settings.Port, // Add aggressive timeouts for offline-first architecture.
settings.Database, cfg.Timeout = 3 * time.Second
) cfg.ReadTimeout = 3 * time.Second
cfg.WriteTimeout = 3 * time.Second
cfg.Params = map[string]string{
"charset": "utf8mb4",
}
return dsn, nil return cfg.FormatDSN(), nil
} }
// DB returns the underlying gorm.DB for advanced operations // DB returns the underlying gorm.DB for advanced operations