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 package v1
import ( import (
"encoding/base64"
"github.com/1Panel-dev/1Panel/backend/app/api/v1/helper" "github.com/1Panel-dev/1Panel/backend/app/api/v1/helper"
"github.com/1Panel-dev/1Panel/backend/app/dto" "github.com/1Panel-dev/1Panel/backend/app/dto"
"github.com/1Panel-dev/1Panel/backend/constant" "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 { if err := helper.CheckBindAndValidate(&req, c); err != nil {
return 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 { if err := databaseService.Create(req); err != nil {
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err) 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 { if err := helper.CheckBindAndValidate(&req, c); err != nil {
return 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)) 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 { if err := helper.CheckBindAndValidate(&req, c); err != nil {
return 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 { if err := databaseService.Update(req); err != nil {
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err) helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err)

View file

@ -237,6 +237,13 @@ type DatabaseInfo struct {
Port uint `json:"port"` Port uint `json:"port"`
Username string `json:"username"` Username string `json:"username"`
Password string `json:"password"` 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"` Description string `json:"description"`
} }
@ -258,6 +265,13 @@ type DatabaseCreate struct {
Port uint `json:"port"` Port uint `json:"port"`
Username string `json:"username" validate:"required"` Username string `json:"username" validate:"required"`
Password string `json:"password" 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"` Description string `json:"description"`
} }
@ -269,6 +283,13 @@ type DatabaseUpdate struct {
Port uint `json:"port"` Port uint `json:"port"`
Username string `json:"username" validate:"required"` Username string `json:"username" validate:"required"`
Password string `json:"password" 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"` Description string `json:"description"`
} }

View file

@ -11,5 +11,12 @@ type Database struct {
Port uint `json:"port" gorm:"type:decimal;not null"` Port uint `json:"port" gorm:"type:decimal;not null"`
Username string `json:"username" gorm:"type:varchar(64)"` Username string `json:"username" gorm:"type:varchar(64)"`
Password string `json:"password" gorm:"type:varchar(64)"` Password string `json:"password" gorm:"type:varchar(64)"`
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);"` Description string `json:"description" gorm:"type:varchar(256);"`
} }

View file

@ -84,6 +84,12 @@ func (u *DatabaseService) CheckDatabase(req dto.DatabaseCreate) bool {
Port: req.Port, Port: req.Port,
Username: req.Username, Username: req.Username,
Password: req.Password, Password: req.Password,
SSL: req.SSL,
RootCert: req.RootCert,
ClientKey: req.ClientKey,
ClientCert: req.ClientCert,
SkipVerify: req.SkipVerify,
Timeout: 6, Timeout: 6,
}); err != nil { }); err != nil {
return false return false
@ -105,6 +111,12 @@ func (u *DatabaseService) Create(req dto.DatabaseCreate) error {
Port: req.Port, Port: req.Port,
Username: req.Username, Username: req.Username,
Password: req.Password, Password: req.Password,
SSL: req.SSL,
RootCert: req.RootCert,
ClientKey: req.ClientKey,
ClientCert: req.ClientCert,
SkipVerify: req.SkipVerify,
Timeout: 6, Timeout: 6,
}); err != nil { }); err != nil {
return err return err
@ -172,6 +184,13 @@ func (u *DatabaseService) Update(req dto.DatabaseUpdate) error {
Port: req.Port, Port: req.Port,
Username: req.Username, Username: req.Username,
Password: req.Password, Password: req.Password,
SSL: req.SSL,
ClientKey: req.ClientKey,
ClientCert: req.ClientCert,
RootCert: req.RootCert,
SkipVerify: req.SkipVerify,
Timeout: 300, Timeout: 300,
}); err != nil { }); err != nil {
return err return err
@ -189,6 +208,10 @@ func (u *DatabaseService) Update(req dto.DatabaseUpdate) error {
upMap["port"] = req.Port upMap["port"] = req.Port
upMap["username"] = req.Username upMap["username"] = req.Username
upMap["password"] = pass 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) 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.Port = databaseItem.Port
dbInfo.Username = databaseItem.Username dbInfo.Username = databaseItem.Username
dbInfo.Password = databaseItem.Password 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 version = databaseItem.Version
} else { } else {

View file

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

View file

@ -45,3 +45,13 @@ var AddDockerSockPath = &gormigrate.Migration{
return nil 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, ":") { if strings.Contains(conn.Address, ":") {
conn.Address = fmt.Sprintf("[%s]", 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) db, err := sql.Open("mysql", connArgs)
if err != nil { if err != nil {
return nil, err return nil, err
@ -57,5 +62,11 @@ func NewMysqlClient(conn client.DBInfo) (MysqlClient, error) {
Password: conn.Password, Password: conn.Password,
Address: conn.Address, Address: conn.Address,
Port: conn.Port, Port: conn.Port,
SSL: conn.SSL,
RootCert: conn.RootCert,
ClientKey: conn.ClientKey,
ClientCert: conn.ClientCert,
SkipVerify: conn.SkipVerify,
}), nil }), nil
} }

View file

@ -1,9 +1,13 @@
package client package client
import ( import (
"crypto/tls"
"crypto/x509"
"errors"
"strings" "strings"
"github.com/1Panel-dev/1Panel/backend/utils/common" "github.com/1Panel-dev/1Panel/backend/utils/common"
"github.com/go-sql-driver/mysql"
) )
type DBInfo struct { type DBInfo struct {
@ -14,6 +18,12 @@ type DBInfo struct {
Username string `json:"userName"` Username string `json:"userName"`
Password string `json:"password"` 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 Timeout uint `json:"timeout"` // second
} }
@ -114,3 +124,45 @@ func randomPassword(user string) string {
} }
return passwdItem + "@" + common.RandStrAndNum(8) 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 Password string
Address string Address string
Port uint Port uint
SSL bool
RootCert string
ClientKey string
ClientCert string
SkipVerify bool
} }
func NewRemote(db Remote) *Remote { func NewRemote(db Remote) *Remote {
@ -224,7 +230,12 @@ func (r *Remote) Backup(info BackupInfo) error {
} }
} }
fileNameItem := info.TargetDir + "/" + strings.TrimSuffix(info.FileName, ".gz") 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) f, _ := os.OpenFile(fileNameItem, os.O_RDWR|os.O_CREATE, 0755)
defer f.Close() defer f.Close()
@ -254,7 +265,12 @@ func (r *Remote) Recover(info RecoverInfo) error {
_, _ = gzipCmd.CombinedOutput() _, _ = 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) f, err := os.Open(fileName)
if err != nil { if err != nil {
return err return err

View file

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

View file

@ -101,13 +101,34 @@ export const listDatabases = (type: string) => {
return http.get<Array<Database.DatabaseOption>>(`/databases/db/list/${type}`); return http.get<Array<Database.DatabaseOption>>(`/databases/db/list/${type}`);
}; };
export const checkDatabase = (params: Database.DatabaseCreate) => { 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) => { 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) => { 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) => { export const deleteCheckDatabase = (id: number) => {
return http.post<Array<string>>(`/databases/db/del/check`, { id: id }); return http.post<Array<string>>(`/databases/db/del/check`, { id: id });

View file

@ -391,6 +391,11 @@ const message = {
address: 'DB address', address: 'DB address',
version: 'DB version', version: 'DB version',
userHelper: 'The root user or a database user with root privileges can access the remote database.', 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: formatHelper:
'The current database character set is {0}, the character set inconsistency may cause recovery failure', 'The current database character set is {0}, the character set inconsistency may cause recovery failure',

View file

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

View file

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

View file

@ -54,6 +54,46 @@
v-model.trim="dialogData.rowData!.password" v-model.trim="dialogData.rowData!.password"
/> />
</el-form-item> </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-form-item :label="$t('commons.table.description')" prop="description">
<el-input clearable v-model.trim="dialogData.rowData!.description" /> <el-input clearable v-model.trim="dialogData.rowData!.description" />
</el-form-item> </el-form-item>
@ -128,6 +168,10 @@ const rules = reactive({
port: [Rules.port], port: [Rules.port],
username: [Rules.requiredInput], username: [Rules.requiredInput],
password: [Rules.requiredInput], password: [Rules.requiredInput],
clientKey: [Rules.requiredInput],
clientCert: [Rules.requiredInput],
caCert: [Rules.requiredInput],
}); });
type FormInstance = InstanceType<typeof ElForm>; type FormInstance = InstanceType<typeof ElForm>;

View file

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