option backup restore #238

This commit is contained in:
Alireza Ahmadi
2025-01-20 01:26:02 +01:00
parent 049cfc5287
commit f3432b119c
11 changed files with 442 additions and 33 deletions
+21
View File
@@ -2,12 +2,14 @@ package api
import ( import (
"encoding/json" "encoding/json"
"s-ui/database"
"s-ui/logger" "s-ui/logger"
"s-ui/service" "s-ui/service"
"s-ui/util" "s-ui/util"
"s-ui/util/common" "s-ui/util/common"
"strconv" "strconv"
"strings" "strings"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -109,6 +111,15 @@ func (a *APIHandler) postHandler(c *gin.Context) {
link := c.Request.FormValue("link") link := c.Request.FormValue("link")
result, _, err := util.GetOutbound(link, 0) result, _, err := util.GetOutbound(link, 0)
jsonObj(c, result, err) jsonObj(c, result, err)
case "importdb":
file, _, err := c.Request.FormFile("db")
if err != nil {
jsonMsg(c, "", err)
return
}
defer file.Close()
err = database.ImportDB(file)
jsonMsg(c, "", err)
default: default:
jsonMsg(c, "failed", common.NewError("unknown action: ", action)) jsonMsg(c, "failed", common.NewError("unknown action: ", action))
} }
@@ -188,6 +199,16 @@ func (a *APIHandler) getHandler(c *gin.Context) {
options := c.Query("o") options := c.Query("o")
keypair := a.ServerService.GenKeypair(kType, options) keypair := a.ServerService.GenKeypair(kType, options)
jsonObj(c, keypair, nil) jsonObj(c, keypair, nil)
case "getdb":
exclude := c.Query("exclude")
db, err := database.GetDb(exclude)
if err != nil {
jsonMsg(c, "", err)
return
}
c.Header("Content-Type", "application/octet-stream")
c.Header("Content-Disposition", "attachment; filename=s-ui_"+time.Now().Format("20060102-150405")+".db")
c.Writer.Write(db)
default: default:
jsonMsg(c, "failed", common.NewError("unknown action: ", action)) jsonMsg(c, "failed", common.NewError("unknown action: ", action))
} }
+271
View File
@@ -0,0 +1,271 @@
package database
import (
"bytes"
"fmt"
"io"
"mime/multipart"
"os"
"path/filepath"
"s-ui/cmd/migration"
"s-ui/config"
"s-ui/database/model"
"s-ui/logger"
"s-ui/util/common"
"strings"
"syscall"
"time"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func GetDb(exclude string) ([]byte, error) {
exclude_changes, exclude_stats := false, false
for _, table := range strings.Split(exclude, ",") {
if table == "changes" {
exclude_changes = true
} else if table == "stats" {
exclude_stats = true
}
}
dir, err := filepath.Abs(filepath.Dir(os.Args[0]))
if err != nil {
return nil, err
}
dbPath := dir + config.GetName() + "_" + time.Now().Format("20060102-200203") + ".db"
backupDb, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
if err != nil {
return nil, err
}
err = backupDb.AutoMigrate(
&model.Setting{},
&model.Tls{},
&model.Inbound{},
&model.Outbound{},
&model.Endpoint{},
&model.User{},
&model.Stats{},
&model.Client{},
&model.Changes{},
)
if err != nil {
return nil, err
}
var settings []model.Setting
var tls []model.Tls
var inbound []model.Inbound
var outbound []model.Outbound
var endpoint []model.Endpoint
var users []model.User
var clients []model.Client
var stats []model.Stats
var changes []model.Changes
// Perform scans and handle errors
if err := db.Model(&model.Setting{}).Scan(&settings).Error; err != nil {
return nil, err
}
if err := db.Model(&model.Tls{}).Scan(&tls).Error; err != nil {
return nil, err
}
if err := db.Model(&model.Inbound{}).Scan(&inbound).Error; err != nil {
return nil, err
}
if err := db.Model(&model.Outbound{}).Scan(&outbound).Error; err != nil {
return nil, err
}
if err := db.Model(&model.Endpoint{}).Scan(&endpoint).Error; err != nil {
return nil, err
}
if err := db.Model(&model.User{}).Scan(&users).Error; err != nil {
return nil, err
}
if err := db.Model(&model.Client{}).Scan(&clients).Error; err != nil {
return nil, err
}
// Save each model
for _, mdl := range []interface{}{settings, tls, inbound, outbound, endpoint, users, clients} {
if err := backupDb.Save(mdl).Error; err != nil {
return nil, err
}
}
if !exclude_stats {
if err := db.Model(&model.Stats{}).Scan(&stats).Error; err != nil {
return nil, err
}
if err := backupDb.Save(stats).Error; err != nil {
return nil, err
}
}
if !exclude_changes {
if err := db.Model(&model.Changes{}).Scan(&changes).Error; err != nil {
return nil, err
}
if err := backupDb.Save(changes).Error; err != nil {
return nil, err
}
}
// Update WAL
err = backupDb.Exec("PRAGMA wal_checkpoint;").Error
if err != nil {
return nil, err
}
bdb, _ := backupDb.DB()
bdb.Close()
// Open the file for reading
file, err := os.Open(dbPath)
if err != nil {
return nil, err
}
defer file.Close()
defer os.Remove(dbPath)
// Read the file contents
fileContents, err := io.ReadAll(file)
if err != nil {
return nil, err
}
return fileContents, nil
}
func ImportDB(file multipart.File) error {
// Check if the file is a SQLite database
isValidDb, err := IsSQLiteDB(file)
if err != nil {
return common.NewErrorf("Error checking db file format: %v", err)
}
if !isValidDb {
return common.NewError("Invalid db file format")
}
// Reset the file reader to the beginning
_, err = file.Seek(0, 0)
if err != nil {
return common.NewErrorf("Error resetting file reader: %v", err)
}
// Save the file as temporary file
tempPath := fmt.Sprintf("%s.temp", config.GetDBPath())
// Remove the existing fallback file (if any) before creating one
_, err = os.Stat(tempPath)
if err == nil {
errRemove := os.Remove(tempPath)
if errRemove != nil {
return common.NewErrorf("Error removing existing temporary db file: %v", errRemove)
}
}
// Create the temporary file
tempFile, err := os.Create(tempPath)
if err != nil {
return common.NewErrorf("Error creating temporary db file: %v", err)
}
defer tempFile.Close()
// Remove temp file before returning
defer os.Remove(tempPath)
// Close old DB
old_db, _ := db.DB()
old_db.Close()
// Save uploaded file to temporary file
_, err = io.Copy(tempFile, file)
if err != nil {
return common.NewErrorf("Error saving db: %v", err)
}
// Check if we can init db or not
newDb, err := gorm.Open(sqlite.Open(tempPath), &gorm.Config{})
if err != nil {
return common.NewErrorf("Error checking db: %v", err)
}
newDb_db, _ := newDb.DB()
newDb_db.Close()
// Backup the current database for fallback
fallbackPath := fmt.Sprintf("%s.backup", config.GetDBPath())
// Remove the existing fallback file (if any)
_, err = os.Stat(fallbackPath)
if err == nil {
errRemove := os.Remove(fallbackPath)
if errRemove != nil {
return common.NewErrorf("Error removing existing fallback db file: %v", errRemove)
}
}
// Move the current database to the fallback location
err = os.Rename(config.GetDBPath(), fallbackPath)
if err != nil {
return common.NewErrorf("Error backing up temporary db file: %v", err)
}
// Remove the temporary file before returning
defer os.Remove(fallbackPath)
// Move temp to DB path
err = os.Rename(tempPath, config.GetDBPath())
if err != nil {
errRename := os.Rename(fallbackPath, config.GetDBPath())
if errRename != nil {
return common.NewErrorf("Error moving db file and restoring fallback: %v", errRename)
}
return common.NewErrorf("Error moving db file: %v", err)
}
// Migrate DB
migration.MigrateDb()
err = InitDB(config.GetDBPath())
if err != nil {
errRename := os.Rename(fallbackPath, config.GetDBPath())
if errRename != nil {
return common.NewErrorf("Error migrating db and restoring fallback: %v", errRename)
}
return common.NewErrorf("Error migrating db: %v", err)
}
// Restart app
err = SendSighup()
if err != nil {
return common.NewErrorf("Error restarting app: %v", err)
}
return nil
}
func IsSQLiteDB(file io.Reader) (bool, error) {
signature := []byte("SQLite format 3\x00")
buf := make([]byte, len(signature))
_, err := file.Read(buf)
if err != nil {
return false, err
}
return bytes.Equal(buf, signature), nil
}
func SendSighup() error {
// Get the current process
process, err := os.FindProcess(os.Getpid())
if err != nil {
return err
}
// Send SIGHUP to the current process
go func() {
time.Sleep(3 * time.Second)
err := process.Signal(syscall.SIGHUP)
if err != nil {
logger.Error("send signal SIGHUP failed:", err)
}
}()
return nil
}
+7 -25
View File
@@ -1,9 +1,6 @@
<template> <template>
<LogVue <LogVue v-model="logModal.visible" :control="logModal" :visible="logModal.visible" />
v-model="logModal.visible" <Backup v-model="backupModal.visible" :control="backupModal" :visible="backupModal.visible" />
:visible="logModal.visible"
@close="closeLogs"
/>
<v-container class="fill-height" :loading="loading"> <v-container class="fill-height" :loading="loading">
<v-responsive :class="reloadItems.length>0 ? 'fill-height text-center' : 'align-center'" > <v-responsive :class="reloadItems.length>0 ? 'fill-height text-center' : 'align-center'" >
<v-row class="d-flex align-center justify-center"> <v-row class="d-flex align-center justify-center">
@@ -46,6 +43,8 @@
</v-row> </v-row>
</v-card> </v-card>
</v-dialog> </v-dialog>
<v-btn variant="tonal" hide-details style="margin-inline-start: 10px;" @click="backupModal.visible = true">{{ $t('main.backup.title') }} <v-icon icon="mdi-backup-restore" /></v-btn>
<v-btn variant="tonal" hide-details style="margin-inline-start: 10px;" @click="logModal.visible = true">{{ $t('basic.log.title') }} <v-icon icon="mdi-list-box-outline" /></v-btn>
</v-col> </v-col>
</v-row> </v-row>
<v-row> <v-row>
@@ -86,18 +85,8 @@
<v-col cols="3">S-UI</v-col> <v-col cols="3">S-UI</v-col>
<v-col cols="9"> <v-col cols="9">
<v-chip density="compact" color="blue"> <v-chip density="compact" color="blue">
<v-tooltip activator="parent" location="top">
{{ $t('main.info.threads') }}: {{ tilesData.sys?.appThreads }}<br />
{{ $t('main.info.memory') }}: {{ HumanReadable.sizeFormat(tilesData.sys?.appMem) }}
</v-tooltip>
v{{ tilesData.sys?.appVersion }} v{{ tilesData.sys?.appVersion }}
</v-chip> </v-chip>
<v-chip density="compact" color="transparent" style="cursor: pointer;" @click="openLogs()">
<v-tooltip activator="parent" location="top">
{{ $t('basic.log.title') + " - S-UI" }}
</v-tooltip>
<v-icon icon="mdi-list-box-outline" color="blue" />
</v-chip>
</v-col> </v-col>
<v-col cols="3">{{ $t('main.info.uptime') }}</v-col> <v-col cols="3">{{ $t('main.info.uptime') }}</v-col>
<v-col cols="9">{{ HumanReadable.formatSecond(tilesData.uptime) }}</v-col> <v-col cols="9">{{ HumanReadable.formatSecond(tilesData.uptime) }}</v-col>
@@ -166,6 +155,7 @@ import History from '@/components/tiles/History.vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue' import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { i18n } from '@/locales' import { i18n } from '@/locales'
import LogVue from '@/layouts/modals/Logs.vue' import LogVue from '@/layouts/modals/Logs.vue'
import Backup from '@/layouts/modals/Backup.vue'
const loading = ref(false) const loading = ref(false)
const menu = ref(false) const menu = ref(false)
@@ -235,17 +225,9 @@ onBeforeUnmount(() => {
stopTimer() stopTimer()
}) })
const logModal = ref({ const logModal = ref({ visible: false })
visible: false,
})
const openLogs = () => { const backupModal = ref({ visible: false })
logModal.value.visible = true
}
const closeLogs = () => {
logModal.value.visible = false
}
const restartSingbox = async () => { const restartSingbox = async () => {
loading.value = true loading.value = true
+91
View File
@@ -0,0 +1,91 @@
<template>
<v-dialog transition="dialog-bottom-transition" width="90%" max-width="500">
<v-card class="rounded-lg">
<v-card-title>
<v-row>
<v-col>{{ $t('main.backup.title') }}</v-col>
<v-spacer></v-spacer>
<v-col cols="auto">
<v-icon icon="mdi-close" @click="control.visible = false" />
</v-col>
</v-row>
</v-card-title>
<v-divider></v-divider>
<v-card-text>
<v-row>
<v-col cols="auto">
<v-checkbox v-model="exclude" :label="$t('main.backup.exclStats')" value="stats" hide-details></v-checkbox>
</v-col>
<v-col cols="auto">
<v-checkbox v-model="exclude" :label="$t('main.backup.exclChanges')" value="changes" hide-details></v-checkbox>
</v-col>
<v-spacer></v-spacer>
<v-col cols="auto" align-self="center">
<v-btn color="primary" @click="backup()" hide-details>{{ $t('main.backup.backup') }}</v-btn>
</v-col>
</v-row>
<v-row>
<v-spacer></v-spacer>
<v-col cols="auto" align-self="center">
<v-btn color="primary" @click="restore()" hide-details>{{ $t('main.backup.restore') }}</v-btn>
</v-col>
</v-row>
</v-card-text>
</v-card>
</v-dialog>
</template>
<script lang="ts">
import HttpUtils from '@/plugins/httputil'
export default {
props: ['control', 'visible'],
data() {
return {
exclude: ["stats", "changes"],
}
},
methods: {
backup() {
const excludeOption = this.exclude.length>0 ? '?exclude=' +this.exclude.join(',') : ''
window.location.href = 'api/getdb' + excludeOption
},
restore() {
const fileInput = document.createElement('input')
fileInput.type = 'file'
fileInput.accept = '.db'
fileInput.addEventListener('change', async (event: Event) => {
const inputElement = event.target as HTMLInputElement
const dbFile = inputElement.files ? inputElement.files[0] : null
if (dbFile) {
const formData = new FormData()
formData.append('db', dbFile)
this.control.visible = false
const uploadMsg = await HttpUtils.post('api/importdb', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
if (uploadMsg.success) {
await new Promise(resolve => setTimeout(resolve, 1000))
location.reload()
}
}
})
fileInput.click()
}
},
watch: {
visible(v) {
if (v) {
this.exclude = ["stats", "changes"]
}
},
},
}
</script>
+5 -5
View File
@@ -6,7 +6,7 @@
<v-col>{{ $t('basic.log.title') }}</v-col> <v-col>{{ $t('basic.log.title') }}</v-col>
<v-spacer></v-spacer> <v-spacer></v-spacer>
<v-col cols="auto"> <v-col cols="auto">
<v-icon icon="mdi-close" @click="$emit('close')" /> <v-icon icon="mdi-close" @click="control.visible = false" />
</v-col> </v-col>
</v-row> </v-row>
</v-card-title> </v-card-title>
@@ -48,10 +48,10 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import HttpUtils from '@/plugins/httputil'; import HttpUtils from '@/plugins/httputil'
export default { export default {
props: ['visible'], props: ['control', 'visible'],
data() { data() {
return { return {
loading: false, loading: false,
@@ -77,11 +77,11 @@ export default {
} }
}, },
watch: { watch: {
visible(newValue) { visible(v) {
this.lines = [] this.lines = []
this.logLevel = 'info' this.logLevel = 'info'
this.logCount = 10 this.logCount = 10
if (newValue) { if (v) {
this.loadData() this.loadData()
} }
}, },
+7
View File
@@ -72,6 +72,13 @@ export default {
threads: "Threads", threads: "Threads",
memory: "Memory", memory: "Memory",
running: "Running" running: "Running"
},
backup: {
title: "Backup & Restore",
backup: "Download Backup",
restore: "Restore",
exclStats: "Exclude graphs",
exclChanges: "Exclude changes",
} }
}, },
objects: { objects: {
+8 -1
View File
@@ -72,7 +72,14 @@ export default {
threads: "نخ‌ها", threads: "نخ‌ها",
memory: "حافظه", memory: "حافظه",
running: "اجرا" running: "اجرا"
} },
backup: {
title: "پشتیبان‌گیری و بازیابی",
backup: "دریافت پشتیبان",
restore: "بازیابی",
exclStats: "بدون گراف‌ها",
exclChanges: "بدون تغییرات",
},
}, },
objects: { objects: {
inbound: "ورودی‌", inbound: "ورودی‌",
+7
View File
@@ -72,6 +72,13 @@ export default {
threads: "Потоки", threads: "Потоки",
memory: "Память", memory: "Память",
running: "Работает" running: "Работает"
},
backup: {
title: "Резервное копирование и восстановление",
backup: "Скачать резервную копию",
restore: "Восстановить",
exclStats: "Исключить графики",
exclChanges: "Исключить изменения",
} }
}, },
objects: { objects: {
+9
View File
@@ -1,3 +1,5 @@
import { title } from "process";
export default { export default {
message: "Chào mừng OHB", message: "Chào mừng OHB",
success: "Thành công", success: "Thành công",
@@ -70,6 +72,13 @@ export default {
threads: "Luồng", threads: "Luồng",
memory: "Bộ nhớ", memory: "Bộ nhớ",
running: "Đang chạy" running: "Đang chạy"
},
backup: {
title: "Sao lưu và khôi phục",
backup: "Tải xuống bản sao lưu",
restore: "Khôi phục",
exclStats: "Loại trừ các biểu đồ",
exclChanges: "Loại trừ các thay đổi",
} }
}, },
objects: { objects: {
+8 -1
View File
@@ -70,7 +70,14 @@ export default {
threads: "线程", threads: "线程",
memory: "内存", memory: "内存",
running: "运行状态" running: "运行状态"
} },
backup: {
title: "备份与恢复",
backup: "下载备份",
restore: "恢复",
exclStats: "排除图表数据",
exclChanges: "排除变更数据",
},
}, },
objects: { objects: {
inbound: "入站", inbound: "入站",
+8 -1
View File
@@ -71,7 +71,14 @@ export default {
threads: "線程", threads: "線程",
memory: "內存", memory: "內存",
running: "運行狀態" running: "運行狀態"
} },
backup: {
title: "備份與恢復",
backup: "下載備份",
restore: "恢復",
exclStats: "排除圖表記錄",
exclChanges: "排除更改記錄",
},
}, },
objects: { objects: {
inbound: "入站", inbound: "入站",