feat: Mysql 远程数据库支持 SSL (#3091)

This commit is contained in:
ssongliu 2023-11-29 10:46:07 +08:00 committed by GitHub
parent 86bc75ff28
commit 10848fb249
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 313 additions and 42 deletions

View File

@ -1,6 +1,8 @@
package v1
import (
"encoding/base64"
"github.com/1Panel-dev/1Panel/backend/app/api/v1/helper"
"github.com/1Panel-dev/1Panel/backend/app/dto"
"github.com/1Panel-dev/1Panel/backend/constant"
@ -21,6 +23,14 @@ func (b *BaseApi) CreateDatabase(c *gin.Context) {
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if req.SSL {
key, _ := base64.StdEncoding.DecodeString(req.ClientKey)
req.ClientKey = string(key)
cert, _ := base64.StdEncoding.DecodeString(req.ClientCert)
req.ClientCert = string(cert)
ca, _ := base64.StdEncoding.DecodeString(req.RootCert)
req.RootCert = string(ca)
}
if err := databaseService.Create(req); err != nil {
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err)
@ -43,6 +53,14 @@ func (b *BaseApi) CheckDatabase(c *gin.Context) {
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if req.SSL {
clientKey, _ := base64.StdEncoding.DecodeString(req.ClientKey)
req.ClientKey = string(clientKey)
clientCert, _ := base64.StdEncoding.DecodeString(req.ClientCert)
req.ClientCert = string(clientCert)
rootCert, _ := base64.StdEncoding.DecodeString(req.RootCert)
req.RootCert = string(rootCert)
}
helper.SuccessWithData(c, databaseService.CheckDatabase(req))
}
@ -173,6 +191,14 @@ func (b *BaseApi) UpdateDatabase(c *gin.Context) {
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if req.SSL {
cKey, _ := base64.StdEncoding.DecodeString(req.ClientKey)
req.ClientKey = string(cKey)
cCert, _ := base64.StdEncoding.DecodeString(req.ClientCert)
req.ClientCert = string(cCert)
ca, _ := base64.StdEncoding.DecodeString(req.RootCert)
req.RootCert = string(ca)
}
if err := databaseService.Update(req); err != nil {
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err)

View File

@ -227,17 +227,24 @@ type DatabaseSearch struct {
}
type DatabaseInfo struct {
ID uint `json:"id"`
CreatedAt time.Time `json:"createdAt"`
Name string `json:"name" validate:"max=256"`
From string `json:"from"`
Type string `json:"type"`
Version string `json:"version"`
Address string `json:"address"`
Port uint `json:"port"`
Username string `json:"username"`
Password string `json:"password"`
Description string `json:"description"`
ID uint `json:"id"`
CreatedAt time.Time `json:"createdAt"`
Name string `json:"name" validate:"max=256"`
From string `json:"from"`
Type string `json:"type"`
Version string `json:"version"`
Address string `json:"address"`
Port uint `json:"port"`
Username string `json:"username"`
Password string `json:"password"`
SSL bool `json:"ssl"`
RootCert string `json:"rootCert"`
ClientKey string `json:"clientKey"`
ClientCert string `json:"clientCert"`
SkipVerify bool `json:"skipVerify"`
Description string `json:"description"`
}
type DatabaseOption struct {
@ -250,25 +257,39 @@ type DatabaseOption struct {
}
type DatabaseCreate struct {
Name string `json:"name" validate:"required,max=256"`
Type string `json:"type" validate:"required"`
From string `json:"from" validate:"required,oneof=local remote"`
Version string `json:"version" validate:"required"`
Address string `json:"address"`
Port uint `json:"port"`
Username string `json:"username" validate:"required"`
Password string `json:"password" validate:"required"`
Name string `json:"name" validate:"required,max=256"`
Type string `json:"type" validate:"required"`
From string `json:"from" validate:"required,oneof=local remote"`
Version string `json:"version" validate:"required"`
Address string `json:"address"`
Port uint `json:"port"`
Username string `json:"username" validate:"required"`
Password string `json:"password" validate:"required"`
SSL bool `json:"ssl"`
RootCert string `json:"rootCert"`
ClientKey string `json:"clientKey"`
ClientCert string `json:"clientCert"`
SkipVerify bool `json:"skipVerify"`
Description string `json:"description"`
}
type DatabaseUpdate struct {
ID uint `json:"id"`
Type string `json:"type" validate:"required"`
Version string `json:"version" validate:"required"`
Address string `json:"address"`
Port uint `json:"port"`
Username string `json:"username" validate:"required"`
Password string `json:"password" validate:"required"`
ID uint `json:"id"`
Type string `json:"type" validate:"required"`
Version string `json:"version" validate:"required"`
Address string `json:"address"`
Port uint `json:"port"`
Username string `json:"username" validate:"required"`
Password string `json:"password" validate:"required"`
SSL bool `json:"ssl"`
RootCert string `json:"rootCert"`
ClientKey string `json:"clientKey"`
ClientCert string `json:"clientCert"`
SkipVerify bool `json:"skipVerify"`
Description string `json:"description"`
}

View File

@ -11,5 +11,12 @@ type Database struct {
Port uint `json:"port" gorm:"type:decimal;not null"`
Username string `json:"username" gorm:"type:varchar(64)"`
Password string `json:"password" gorm:"type:varchar(64)"`
Description string `json:"description" gorm:"type:varchar(256);"`
SSL bool `json:"ssl"`
RootCert string `json:"rootCert" gorm:"type:longText"`
ClientKey string `json:"clientKey" gorm:"type:longText"`
ClientCert string `json:"clientCert" gorm:"type:longText"`
SkipVerify bool `json:"skipVerify"`
Description string `json:"description" gorm:"type:varchar(256);"`
}

View File

@ -84,7 +84,13 @@ func (u *DatabaseService) CheckDatabase(req dto.DatabaseCreate) bool {
Port: req.Port,
Username: req.Username,
Password: req.Password,
Timeout: 6,
SSL: req.SSL,
RootCert: req.RootCert,
ClientKey: req.ClientKey,
ClientCert: req.ClientCert,
SkipVerify: req.SkipVerify,
Timeout: 6,
}); err != nil {
return false
}
@ -105,7 +111,13 @@ func (u *DatabaseService) Create(req dto.DatabaseCreate) error {
Port: req.Port,
Username: req.Username,
Password: req.Password,
Timeout: 6,
SSL: req.SSL,
RootCert: req.RootCert,
ClientKey: req.ClientKey,
ClientCert: req.ClientCert,
SkipVerify: req.SkipVerify,
Timeout: 6,
}); err != nil {
return err
}
@ -172,7 +184,14 @@ func (u *DatabaseService) Update(req dto.DatabaseUpdate) error {
Port: req.Port,
Username: req.Username,
Password: req.Password,
Timeout: 300,
SSL: req.SSL,
ClientKey: req.ClientKey,
ClientCert: req.ClientCert,
RootCert: req.RootCert,
SkipVerify: req.SkipVerify,
Timeout: 300,
}); err != nil {
return err
}
@ -189,6 +208,10 @@ func (u *DatabaseService) Update(req dto.DatabaseUpdate) error {
upMap["port"] = req.Port
upMap["username"] = req.Username
upMap["password"] = pass
upMap["description"] = req.Description
upMap["ssl"] = req.SSL
upMap["client_key"] = req.ClientKey
upMap["client_cert"] = req.ClientCert
upMap["root_cert"] = req.RootCert
upMap["skip_verify"] = req.SkipVerify
return databaseRepo.Update(req.ID, upMap)
}

View File

@ -641,6 +641,11 @@ func LoadMysqlClientByFrom(database string) (mysql.MysqlClient, string, error) {
dbInfo.Port = databaseItem.Port
dbInfo.Username = databaseItem.Username
dbInfo.Password = databaseItem.Password
dbInfo.SSL = databaseItem.SSL
dbInfo.ClientKey = databaseItem.ClientKey
dbInfo.ClientCert = databaseItem.ClientCert
dbInfo.RootCert = databaseItem.RootCert
dbInfo.SkipVerify = databaseItem.SkipVerify
version = databaseItem.Version
} else {

View File

@ -57,6 +57,7 @@ func Init() {
migrations.UpdateWebsiteSSL,
migrations.AddWebsiteCA,
migrations.AddDockerSockPath,
migrations.AddDatabaseSSL,
})
if err := m.Migrate(); err != nil {
global.LOG.Error(err)

View File

@ -45,3 +45,13 @@ var AddDockerSockPath = &gormigrate.Migration{
return nil
},
}
var AddDatabaseSSL = &gormigrate.Migration{
ID: "20231126-add-database-ssl",
Migrate: func(tx *gorm.DB) error {
if err := tx.AutoMigrate(&model.Database{}); err != nil {
return err
}
return nil
},
}

View File

@ -35,7 +35,12 @@ func NewMysqlClient(conn client.DBInfo) (MysqlClient, error) {
if strings.Contains(conn.Address, ":") {
conn.Address = fmt.Sprintf("[%s]", conn.Address)
}
connArgs := fmt.Sprintf("%s:%s@tcp(%s:%d)/?charset=utf8", conn.Username, conn.Password, conn.Address, conn.Port)
tlsItem, err := client.ConnWithSSL(conn.SSL, conn.SkipVerify, conn.ClientKey, conn.ClientCert, conn.RootCert)
if err != nil {
return nil, err
}
connArgs := fmt.Sprintf("%s:%s@tcp(%s:%d)/?charset=utf8%s", conn.Username, conn.Password, conn.Address, conn.Port, tlsItem)
db, err := sql.Open("mysql", connArgs)
if err != nil {
return nil, err
@ -57,5 +62,11 @@ func NewMysqlClient(conn client.DBInfo) (MysqlClient, error) {
Password: conn.Password,
Address: conn.Address,
Port: conn.Port,
SSL: conn.SSL,
RootCert: conn.RootCert,
ClientKey: conn.ClientKey,
ClientCert: conn.ClientCert,
SkipVerify: conn.SkipVerify,
}), nil
}

View File

@ -1,9 +1,13 @@
package client
import (
"crypto/tls"
"crypto/x509"
"errors"
"strings"
"github.com/1Panel-dev/1Panel/backend/utils/common"
"github.com/go-sql-driver/mysql"
)
type DBInfo struct {
@ -14,6 +18,12 @@ type DBInfo struct {
Username string `json:"userName"`
Password string `json:"password"`
SSL bool `json:"ssl"`
RootCert string `json:"rootCert"`
ClientKey string `json:"clientKey"`
ClientCert string `json:"clientCert"`
SkipVerify bool `json:"skipVerify"`
Timeout uint `json:"timeout"` // second
}
@ -114,3 +124,45 @@ func randomPassword(user string) string {
}
return passwdItem + "@" + common.RandStrAndNum(8)
}
func VerifyPeerCertFunc(pool *x509.CertPool) func([][]byte, [][]*x509.Certificate) error {
return func(rawCerts [][]byte, _ [][]*x509.Certificate) error {
if len(rawCerts) == 0 {
return errors.New("no certificates available to verify")
}
cert, err := x509.ParseCertificate(rawCerts[0])
if err != nil {
return err
}
opts := x509.VerifyOptions{Roots: pool}
if _, err = cert.Verify(opts); err != nil {
return err
}
return nil
}
}
func ConnWithSSL(ssl, skipVerify bool, clientKey, clientCert, rootCert string) (string, error) {
if !ssl {
return "", nil
}
pool := x509.NewCertPool()
if ok := pool.AppendCertsFromPEM([]byte(rootCert)); !ok {
return "", errors.New("unable to append root cert to pool")
}
cert, err := tls.X509KeyPair([]byte(clientCert), []byte(clientKey))
if err != nil {
return "", err
}
if err := mysql.RegisterTLSConfig("cloudsql", &tls.Config{
RootCAs: pool,
Certificates: []tls.Certificate{cert},
InsecureSkipVerify: skipVerify,
VerifyPeerCertificate: VerifyPeerCertFunc(pool),
}); err != nil {
return "", err
}
return "&tls=cloudsql", nil
}

View File

@ -23,6 +23,12 @@ type Remote struct {
Password string
Address string
Port uint
SSL bool
RootCert string
ClientKey string
ClientCert string
SkipVerify bool
}
func NewRemote(db Remote) *Remote {
@ -224,7 +230,12 @@ func (r *Remote) Backup(info BackupInfo) error {
}
}
fileNameItem := info.TargetDir + "/" + strings.TrimSuffix(info.FileName, ".gz")
dns := fmt.Sprintf("%s:%s@tcp(%s:%v)/%s?charset=%s&parseTime=true&loc=Asia%sShanghai", r.User, r.Password, r.Address, r.Port, info.Name, info.Format, "%2F")
tlsItem, err := ConnWithSSL(r.SSL, r.SkipVerify, r.ClientKey, r.ClientCert, r.RootCert)
if err != nil {
return err
}
dns := fmt.Sprintf("%s:%s@tcp(%s:%v)/%s?charset=%s&parseTime=true&loc=Asia%sShanghai%s", r.User, r.Password, r.Address, r.Port, info.Name, info.Format, "%2F", tlsItem)
f, _ := os.OpenFile(fileNameItem, os.O_RDWR|os.O_CREATE, 0755)
defer f.Close()
@ -254,7 +265,12 @@ func (r *Remote) Recover(info RecoverInfo) error {
_, _ = gzipCmd.CombinedOutput()
}()
}
dns := fmt.Sprintf("%s:%s@tcp(%s:%v)/%s?charset=%s&parseTime=true&loc=Asia%sShanghai", r.User, r.Password, r.Address, r.Port, info.Name, info.Format, "%2F")
tlsItem, err := ConnWithSSL(r.SSL, r.SkipVerify, r.ClientKey, r.ClientCert, r.RootCert)
if err != nil {
return err
}
dns := fmt.Sprintf("%s:%s@tcp(%s:%v)/%s?charset=%s&parseTime=true&loc=Asia%sShanghai%s", r.User, r.Password, r.Address, r.Port, info.Name, info.Format, "%2F", tlsItem)
f, err := os.Open(fileName)
if err != nil {
return err

View File

@ -213,6 +213,13 @@ export namespace Database {
port: number;
username: string;
password: string;
ssl: boolean;
rootCert: string;
clientKey: string;
clientCert: string;
skipVerify: boolean;
description: string;
}
export interface SearchDatabasePage {
@ -239,6 +246,13 @@ export namespace Database {
port: number;
username: string;
password: string;
ssl: boolean;
rootCert: string;
clientKey: string;
clientCert: string;
skipVerify: boolean;
description: string;
}
export interface DatabaseUpdate {
@ -248,6 +262,13 @@ export namespace Database {
port: number;
username: string;
password: string;
ssl: boolean;
rootCert: string;
clientKey: string;
clientCert: string;
skipVerify: boolean;
description: string;
}
export interface DatabaseDelete {

View File

@ -101,13 +101,34 @@ export const listDatabases = (type: string) => {
return http.get<Array<Database.DatabaseOption>>(`/databases/db/list/${type}`);
};
export const checkDatabase = (params: Database.DatabaseCreate) => {
return http.post<boolean>(`/databases/db/check`, params, TimeoutEnum.T_40S);
let request = deepCopy(params) as Database.DatabaseCreate;
if (request.ssl) {
request.clientKey = Base64.encode(request.clientKey);
request.clientCert = Base64.encode(request.clientCert);
request.rootCert = Base64.encode(request.rootCert);
}
return http.post<boolean>(`/databases/db/check`, request, TimeoutEnum.T_40S);
};
export const addDatabase = (params: Database.DatabaseCreate) => {
return http.post(`/databases/db`, params, TimeoutEnum.T_40S);
let request = deepCopy(params) as Database.DatabaseCreate;
if (request.ssl) {
request.clientKey = Base64.encode(request.clientKey);
request.clientCert = Base64.encode(request.clientCert);
request.rootCert = Base64.encode(request.rootCert);
}
return http.post(`/databases/db`, request, TimeoutEnum.T_40S);
};
export const editDatabase = (params: Database.DatabaseUpdate) => {
return http.post(`/databases/db/update`, params, TimeoutEnum.T_40S);
let request = deepCopy(params) as Database.DatabaseCreate;
if (request.ssl) {
request.clientKey = Base64.encode(request.clientKey);
request.clientCert = Base64.encode(request.clientCert);
request.rootCert = Base64.encode(request.rootCert);
}
return http.post(`/databases/db/update`, request, TimeoutEnum.T_40S);
};
export const deleteCheckDatabase = (id: number) => {
return http.post<Array<string>>(`/databases/db/del/check`, { id: id });

View File

@ -391,6 +391,11 @@ const message = {
address: 'DB address',
version: 'DB version',
userHelper: 'The root user or a database user with root privileges can access the remote database.',
ssl: 'Use SSL',
clientKey: 'Client Private Key',
clientCert: 'Client Certificate',
caCert: 'CA Certificate',
skipVerify: 'Ignore Certificate Validity Check',
formatHelper:
'The current database character set is {0}, the character set inconsistency may cause recovery failure',

View File

@ -383,6 +383,11 @@ const message = {
address: '數據庫地址',
version: '數據庫版本',
userHelper: 'root 用戶或者擁有 root 權限的數據庫用戶',
ssl: '使用 SSL',
clientKey: '客户端私钥',
clientCert: '客户端证书',
caCert: 'CA 证书',
skipVerify: '忽略校验证书可用性检测',
formatHelper: '當前資料庫字符集為 {0}字符集不一致可能導致恢復失敗',
selectFile: '選擇文件',

View File

@ -383,6 +383,11 @@ const message = {
address: '数据库地址',
version: '数据库版本',
userHelper: 'root 用户或者拥有 root 权限的数据库用户',
ssl: '使用 SSL',
clientKey: '客户端私钥',
clientCert: '客户端证书',
caCert: 'CA 证书',
skipVerify: '忽略校验证书可用性检测',
formatHelper: '当前数据库字符集为 {0}字符集不一致可能导致恢复失败',
selectFile: '选择文件',

View File

@ -54,6 +54,46 @@
v-model.trim="dialogData.rowData!.password"
/>
</el-form-item>
<el-form-item>
<el-checkbox
@change="isOK = false"
v-model="dialogData.rowData!.ssl"
:label="$t('database.ssl')"
/>
</el-form-item>
<div v-if="dialogData.rowData!.ssl">
<el-form-item :label="$t('database.clientKey')" prop="clientKey">
<el-input
type="textarea"
@change="isOK = false"
clearable
v-model="dialogData.rowData!.clientKey"
/>
</el-form-item>
<el-form-item :label="$t('database.clientCert')" prop="clientCert">
<el-input
type="textarea"
@change="isOK = false"
clearable
v-model="dialogData.rowData!.clientCert"
/>
</el-form-item>
<el-form-item :label="$t('database.caCert')" prop="rootCert">
<el-input
type="textarea"
@change="isOK = false"
clearable
v-model="dialogData.rowData!.rootCert"
/>
</el-form-item>
<el-form-item>
<el-checkbox
@change="isOK = false"
v-model="dialogData.rowData!.skipVerify"
:label="$t('database.skipVerify')"
/>
</el-form-item>
</div>
<el-form-item :label="$t('commons.table.description')" prop="description">
<el-input clearable v-model.trim="dialogData.rowData!.description" />
</el-form-item>
@ -128,6 +168,10 @@ const rules = reactive({
port: [Rules.port],
username: [Rules.requiredInput],
password: [Rules.requiredInput],
clientKey: [Rules.requiredInput],
clientCert: [Rules.requiredInput],
caCert: [Rules.requiredInput],
});
type FormInstance = InstanceType<typeof ElForm>;

View File

@ -43,7 +43,7 @@
</span>
</el-col>
<el-col :xs="12" :sm="12" :md="6" :lg="6" :xl="6" align="center">
<el-popover placement="bottom" :width="160" trigger="hover">
<el-popover placement="bottom" :width="160" trigger="hover" v-if="chartsOption['memory']">
<el-tag style="font-weight: 500">{{ $t('home.mem') }}:</el-tag>
<el-tag class="tagClass">
{{ $t('home.total') }}: {{ formatNumber(currentInfo.memoryTotal / 1024 / 1024) }} MB
@ -57,10 +57,8 @@
<el-tag class="tagClass">
{{ $t('home.percent') }}: {{ formatNumber(currentInfo.memoryUsedPercent) }}%
</el-tag>
<div v-if="currentInfo.swapMemoryTotal">
<el-row :gutter="5" class="mt-2">
<el-tag style="font-weight: 500">{{ $t('home.swapMem') }}:</el-tag>
</el-row>
<div v-if="currentInfo.swapMemoryTotal" class="mt-2">
<el-tag style="font-weight: 500">{{ $t('home.swapMem') }}:</el-tag>
<el-tag class="tagClass">
{{ $t('home.total') }}: {{ formatNumber(currentInfo.swapMemoryTotal / 1024 / 1024) }} MB
</el-tag>