323 lines
9.8 KiB
Go
323 lines
9.8 KiB
Go
// Package mysql provides an optional MySQL adapter over database.OpenConnector.
|
|
// Each pool owns independent driver settings; no global driver registry changes
|
|
// are made. It never migrates tables or retries application transactions.
|
|
package mysql
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"database/sql/driver"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"os"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.yuntuo.hk/k2safe/go-omni/database"
|
|
"git.yuntuo.hk/k2safe/go-omni/logx"
|
|
mysqldriver "github.com/go-sql-driver/mysql"
|
|
)
|
|
|
|
type Config struct {
|
|
Host string `json:"host"`
|
|
Port int `json:"port"`
|
|
Socket string `json:"socket"`
|
|
User string `json:"user"`
|
|
Password string `json:"password" env:",secret"`
|
|
Database string `json:"database"`
|
|
ParseTime bool `json:"parseTime"`
|
|
Location string `json:"location"`
|
|
Charset string `json:"charset"`
|
|
Collation string `json:"collation"`
|
|
// Session values are SQL expressions, as required by the driver, for
|
|
// example time_zone: "'+00:00'". They are trusted deployment configuration.
|
|
Session map[string]string `json:"session"`
|
|
ConnectTimeout time.Duration `json:"connectTimeout"`
|
|
ReadTimeout time.Duration `json:"readTimeout"`
|
|
WriteTimeout time.Duration `json:"writeTimeout"`
|
|
MultiStatements bool `json:"multiStatements"`
|
|
TLS TLSConfig `json:"tls"`
|
|
Pool database.PoolConfig `json:"pool"`
|
|
}
|
|
type TLSConfig struct {
|
|
Mode string `json:"mode"` // required (default) or disabled
|
|
CAFile string `json:"caFile"`
|
|
CertFile string `json:"certFile"`
|
|
KeyFile string `json:"keyFile"`
|
|
ServerName string `json:"serverName"`
|
|
}
|
|
|
|
func DefaultConfig() Config {
|
|
return Config{Host: "127.0.0.1", Port: 3306, ParseTime: true, Location: "UTC", Charset: "utf8mb4", ConnectTimeout: 5 * time.Second, ReadTimeout: 5 * time.Second, WriteTimeout: 5 * time.Second, TLS: TLSConfig{Mode: "required"}, Pool: database.DefaultPoolConfig()}
|
|
}
|
|
|
|
var identifier = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
|
|
|
// Validate does not read certificate files or open connections. All defaults
|
|
// come from DefaultConfig; explicit zero I/O timeouts mean no driver deadline.
|
|
func (c Config) Validate() error {
|
|
if c.Socket == "" && (c.Host == "" || strings.ContainsAny(c.Host, "/\r\n\t ") || c.Port < 1 || c.Port > 65535) {
|
|
return errors.New("mysql: invalid host or port")
|
|
}
|
|
if strings.ContainsRune(c.Socket, 0) || strings.ContainsRune(c.Database, 0) || strings.ContainsRune(c.User, 0) {
|
|
return errors.New("mysql: invalid connection field")
|
|
}
|
|
if c.ConnectTimeout < 0 || c.ReadTimeout < 0 || c.WriteTimeout < 0 {
|
|
return errors.New("mysql: timeouts cannot be negative")
|
|
}
|
|
if err := c.Pool.Validate(); err != nil {
|
|
return err
|
|
}
|
|
if c.Location != "" {
|
|
if _, err := time.LoadLocation(c.Location); err != nil {
|
|
return errors.New("mysql: invalid time location")
|
|
}
|
|
}
|
|
if c.Charset != "" && !identifier.MatchString(c.Charset) {
|
|
return errors.New("mysql: invalid charset")
|
|
}
|
|
if c.Collation != "" && !identifier.MatchString(c.Collation) {
|
|
return errors.New("mysql: invalid collation")
|
|
}
|
|
for key := range c.Session {
|
|
if !identifier.MatchString(key) {
|
|
return errors.New("mysql: invalid session variable name")
|
|
}
|
|
}
|
|
if c.TLS.Mode != "" && c.TLS.Mode != "required" && c.TLS.Mode != "disabled" {
|
|
return errors.New("mysql: TLS mode must be required or disabled")
|
|
}
|
|
if (c.TLS.CertFile == "") != (c.TLS.KeyFile == "") {
|
|
return errors.New("mysql: TLS client certificate and key must be configured together")
|
|
}
|
|
if c.TLS.Mode == "disabled" && (c.TLS.CAFile != "" || c.TLS.CertFile != "" || c.TLS.ServerName != "") {
|
|
return errors.New("mysql: TLS fields cannot be used when disabled")
|
|
}
|
|
if c.Socket != "" && c.TLS.Mode != "disabled" && c.TLS.ServerName == "" {
|
|
return errors.New("mysql: TLS over a unix socket requires serverName")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Error has a safe summary; errors.Is/As retain driver/verification causes.
|
|
// Do not log raw configuration or a driver's manually extracted error fields.
|
|
type Error struct {
|
|
Operation string
|
|
cause error
|
|
}
|
|
|
|
func (e *Error) Error() string { return "mysql: " + e.Operation + " failed" }
|
|
func (e *Error) Unwrap() error { return e.cause }
|
|
|
|
type Check func(context.Context, *database.DB) error
|
|
type options struct {
|
|
logger *logx.Logger
|
|
checks []Check
|
|
configure func(*mysqldriver.Config) error
|
|
}
|
|
type Option func(*options)
|
|
|
|
func WithLogger(l *logx.Logger) Option { return func(o *options) { o.logger = l } }
|
|
|
|
// WithStartupCheck runs after Ping, before ownership reaches the caller. A
|
|
// failure closes the pool. Checks must honor ctx and must not close the pool.
|
|
func WithStartupCheck(check Check) Option {
|
|
return func(o *options) {
|
|
if check != nil {
|
|
o.checks = append(o.checks, check)
|
|
}
|
|
}
|
|
}
|
|
|
|
// WithDriverConfig provides an advanced, per-pool escape hatch. Configuration
|
|
// is cloned after this callback; keep credentials out of callback errors/logs.
|
|
func WithDriverConfig(fn func(*mysqldriver.Config) error) Option {
|
|
return func(o *options) { o.configure = fn }
|
|
}
|
|
|
|
func settings(opts []Option) options {
|
|
var o options
|
|
for _, opt := range opts {
|
|
if opt != nil {
|
|
opt(&o)
|
|
}
|
|
}
|
|
return o
|
|
}
|
|
|
|
// Open returns the existing Omni pool; .DB is the same standard sql.DB for
|
|
// repositories or ORMs. Ping and startup checks share one bounded deadline.
|
|
func Open(ctx context.Context, cfg Config, opts ...Option) (db *database.DB, err error) {
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
if err = ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
o := settings(opts)
|
|
if o.logger == nil {
|
|
o.logger = logx.FromContext(ctx)
|
|
}
|
|
connector, err := connector(cfg, o)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return openPool(ctx, connector, cfg.Pool, o.checks)
|
|
}
|
|
|
|
func openPool(ctx context.Context, connector driver.Connector, pool database.PoolConfig, checks []Check) (db *database.DB, err error) {
|
|
checkCtx, cancel := context.WithTimeout(ctx, pool.PingTimeout)
|
|
defer cancel()
|
|
db, err = database.OpenConnector(checkCtx, connector, pool)
|
|
if err != nil {
|
|
return nil, &Error{Operation: "connect", cause: err}
|
|
}
|
|
defer func() {
|
|
if p := recover(); p != nil {
|
|
_ = db.Close()
|
|
panic(p)
|
|
}
|
|
if err != nil {
|
|
err = errors.Join(err, db.Close())
|
|
db = nil
|
|
}
|
|
}()
|
|
for _, check := range checks {
|
|
if err = check(checkCtx, db); err != nil {
|
|
return db, &Error{Operation: "startup check", cause: err}
|
|
}
|
|
if err = checkCtx.Err(); err != nil {
|
|
return db, &Error{Operation: "startup check", cause: err}
|
|
}
|
|
}
|
|
return db, nil
|
|
}
|
|
|
|
// NewConnector builds a standard connector for callers that own pool creation.
|
|
// Startup checks are only executed by Open.
|
|
func NewConnector(cfg Config, opts ...Option) (driver.Connector, error) {
|
|
o := settings(opts)
|
|
if len(o.checks) > 0 {
|
|
return nil, errors.New("mysql: startup checks require Open, not NewConnector")
|
|
}
|
|
return connector(cfg, o)
|
|
}
|
|
func connector(cfg Config, o options) (driver.Connector, error) {
|
|
d, err := driverConfig(cfg, o)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
c, err := mysqldriver.NewConnector(d)
|
|
if err != nil {
|
|
return nil, &Error{Operation: "connector configuration", cause: err}
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
func driverConfig(cfg Config, o options) (*mysqldriver.Config, error) {
|
|
if err := cfg.Validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
d := mysqldriver.NewConfig()
|
|
d.User, d.Passwd, d.DBName = cfg.User, cfg.Password, cfg.Database
|
|
d.Net, d.Addr = "tcp", net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port))
|
|
if cfg.Socket != "" {
|
|
d.Net, d.Addr = "unix", cfg.Socket
|
|
}
|
|
d.ParseTime = cfg.ParseTime
|
|
if cfg.Location != "" {
|
|
d.Loc, _ = time.LoadLocation(cfg.Location)
|
|
}
|
|
if cfg.Charset != "" {
|
|
if err := d.Apply(mysqldriver.Charset(cfg.Charset, cfg.Collation)); err != nil {
|
|
return nil, &Error{Operation: "charset", cause: err}
|
|
}
|
|
} else {
|
|
d.Collation = cfg.Collation
|
|
}
|
|
d.Params = make(map[string]string, len(cfg.Session))
|
|
for k, v := range cfg.Session {
|
|
d.Params[k] = v
|
|
}
|
|
d.Timeout, d.ReadTimeout, d.WriteTimeout = cfg.ConnectTimeout, cfg.ReadTimeout, cfg.WriteTimeout
|
|
d.MultiStatements = cfg.MultiStatements
|
|
logger := o.logger
|
|
if logger == nil {
|
|
logger = logx.FromContext(nil)
|
|
}
|
|
d.Logger = driverLog{logger: logger, secret: cfg.Password}
|
|
if cfg.TLS.Mode != "disabled" {
|
|
name := cfg.TLS.ServerName
|
|
if name == "" {
|
|
name = cfg.Host
|
|
}
|
|
t := &tls.Config{MinVersion: tls.VersionTLS12, ServerName: name}
|
|
if cfg.TLS.CAFile != "" {
|
|
pem, err := readCertificate(cfg.TLS.CAFile)
|
|
if err != nil {
|
|
return nil, &Error{Operation: "read CA", cause: err}
|
|
}
|
|
t.RootCAs = x509.NewCertPool()
|
|
if !t.RootCAs.AppendCertsFromPEM(pem) {
|
|
return nil, errors.New("mysql: invalid CA certificate")
|
|
}
|
|
}
|
|
if cfg.TLS.CertFile != "" {
|
|
cert, err := readCertificate(cfg.TLS.CertFile)
|
|
if err != nil {
|
|
return nil, &Error{Operation: "read client certificate", cause: err}
|
|
}
|
|
key, err := readCertificate(cfg.TLS.KeyFile)
|
|
if err != nil {
|
|
return nil, &Error{Operation: "read client key", cause: err}
|
|
}
|
|
pair, err := tls.X509KeyPair(cert, key)
|
|
if err != nil {
|
|
return nil, &Error{Operation: "client key pair", cause: err}
|
|
}
|
|
t.Certificates = []tls.Certificate{pair}
|
|
}
|
|
d.TLS = t
|
|
}
|
|
if o.configure != nil {
|
|
if err := o.configure(d); err != nil {
|
|
return nil, &Error{Operation: "custom configuration", cause: err}
|
|
}
|
|
}
|
|
return d.Clone(), nil
|
|
}
|
|
|
|
func readCertificate(path string) ([]byte, error) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
data, err := io.ReadAll(io.LimitReader(f, (4<<20)+1))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(data) > 4<<20 {
|
|
return nil, errors.New("certificate file exceeds 4 MiB")
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
type driverLog struct {
|
|
logger *logx.Logger
|
|
secret string
|
|
}
|
|
|
|
func (l driverLog) Print(values ...any) {
|
|
message := fmt.Sprint(values...)
|
|
if l.secret != "" {
|
|
message = strings.ReplaceAll(message, l.secret, "[REDACTED]")
|
|
}
|
|
l.logger.Error("database.driver", "driver", "mysql", "error", message)
|
|
}
|