1211 lines
32 KiB
Go
1211 lines
32 KiB
Go
package service
|
||
|
||
import (
|
||
"archive/zip"
|
||
"bufio"
|
||
"bytes"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"io/fs"
|
||
"mime/multipart"
|
||
"net/http"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"runtime"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
"context"
|
||
|
||
"x-ui/config"
|
||
"x-ui/database"
|
||
"x-ui/logger"
|
||
"x-ui/util/common"
|
||
"x-ui/util/sys"
|
||
"x-ui/xray"
|
||
|
||
"github.com/google/uuid"
|
||
"github.com/shirou/gopsutil/v4/cpu"
|
||
"github.com/shirou/gopsutil/v4/disk"
|
||
"github.com/shirou/gopsutil/v4/host"
|
||
"github.com/shirou/gopsutil/v4/load"
|
||
"github.com/shirou/gopsutil/v4/mem"
|
||
"github.com/shirou/gopsutil/v4/net"
|
||
)
|
||
|
||
type ProcessState string
|
||
|
||
const (
|
||
Running ProcessState = "running"
|
||
Stop ProcessState = "stop"
|
||
Error ProcessState = "error"
|
||
)
|
||
|
||
type Status struct {
|
||
T time.Time `json:"-"`
|
||
Cpu float64 `json:"cpu"`
|
||
CpuCores int `json:"cpuCores"`
|
||
LogicalPro int `json:"logicalPro"`
|
||
CpuSpeedMhz float64 `json:"cpuSpeedMhz"`
|
||
Mem struct {
|
||
Current uint64 `json:"current"`
|
||
Total uint64 `json:"total"`
|
||
} `json:"mem"`
|
||
Swap struct {
|
||
Current uint64 `json:"current"`
|
||
Total uint64 `json:"total"`
|
||
} `json:"swap"`
|
||
Disk struct {
|
||
Current uint64 `json:"current"`
|
||
Total uint64 `json:"total"`
|
||
} `json:"disk"`
|
||
Xray struct {
|
||
State ProcessState `json:"state"`
|
||
ErrorMsg string `json:"errorMsg"`
|
||
Version string `json:"version"`
|
||
} `json:"xray"`
|
||
Uptime uint64 `json:"uptime"`
|
||
Loads []float64 `json:"loads"`
|
||
TcpCount int `json:"tcpCount"`
|
||
UdpCount int `json:"udpCount"`
|
||
NetIO struct {
|
||
Up uint64 `json:"up"`
|
||
Down uint64 `json:"down"`
|
||
} `json:"netIO"`
|
||
NetTraffic struct {
|
||
Sent uint64 `json:"sent"`
|
||
Recv uint64 `json:"recv"`
|
||
} `json:"netTraffic"`
|
||
PublicIP struct {
|
||
IPv4 string `json:"ipv4"`
|
||
IPv6 string `json:"ipv6"`
|
||
} `json:"publicIP"`
|
||
AppStats struct {
|
||
Threads uint32 `json:"threads"`
|
||
Mem uint64 `json:"mem"`
|
||
Uptime uint64 `json:"uptime"`
|
||
} `json:"appStats"`
|
||
}
|
||
|
||
type Release struct {
|
||
TagName string `json:"tag_name"`
|
||
}
|
||
|
||
type ServerService struct {
|
||
xrayService XrayService
|
||
inboundService InboundService
|
||
tgService TelegramService
|
||
cachedIPv4 string
|
||
cachedIPv6 string
|
||
noIPv6 bool
|
||
}
|
||
|
||
// 【新增方法】: 用于从外部注入 TelegramService 实例
|
||
func (s *ServerService) SetTelegramService(tgService TelegramService) {
|
||
s.tgService = tgService
|
||
}
|
||
|
||
func getPublicIP(url string) string {
|
||
client := &http.Client{
|
||
Timeout: 3 * time.Second,
|
||
}
|
||
|
||
resp, err := client.Get(url)
|
||
if err != nil {
|
||
return "N/A"
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
// Don't retry if access is blocked or region-restricted
|
||
if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusUnavailableForLegalReasons {
|
||
return "N/A"
|
||
}
|
||
if resp.StatusCode != http.StatusOK {
|
||
return "N/A"
|
||
}
|
||
|
||
ip, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
return "N/A"
|
||
}
|
||
|
||
ipString := strings.TrimSpace(string(ip))
|
||
if ipString == "" {
|
||
return "N/A"
|
||
}
|
||
|
||
return ipString
|
||
}
|
||
|
||
func (s *ServerService) GetStatus(lastStatus *Status) *Status {
|
||
now := time.Now()
|
||
status := &Status{
|
||
T: now,
|
||
}
|
||
|
||
// CPU stats
|
||
percents, err := cpu.Percent(0, false)
|
||
if err != nil {
|
||
logger.Warning("get cpu percent failed:", err)
|
||
} else {
|
||
status.Cpu = percents[0]
|
||
}
|
||
|
||
status.CpuCores, err = cpu.Counts(false)
|
||
if err != nil {
|
||
logger.Warning("get cpu cores count failed:", err)
|
||
}
|
||
|
||
status.LogicalPro = runtime.NumCPU()
|
||
|
||
cpuInfos, err := cpu.Info()
|
||
if err != nil {
|
||
logger.Warning("get cpu info failed:", err)
|
||
} else if len(cpuInfos) > 0 {
|
||
status.CpuSpeedMhz = cpuInfos[0].Mhz
|
||
} else {
|
||
logger.Warning("could not find cpu info")
|
||
}
|
||
|
||
// Uptime
|
||
upTime, err := host.Uptime()
|
||
if err != nil {
|
||
logger.Warning("get uptime failed:", err)
|
||
} else {
|
||
status.Uptime = upTime
|
||
}
|
||
|
||
// Memory stats
|
||
memInfo, err := mem.VirtualMemory()
|
||
if err != nil {
|
||
logger.Warning("get virtual memory failed:", err)
|
||
} else {
|
||
status.Mem.Current = memInfo.Used
|
||
status.Mem.Total = memInfo.Total
|
||
}
|
||
|
||
swapInfo, err := mem.SwapMemory()
|
||
if err != nil {
|
||
logger.Warning("get swap memory failed:", err)
|
||
} else {
|
||
status.Swap.Current = swapInfo.Used
|
||
status.Swap.Total = swapInfo.Total
|
||
}
|
||
|
||
// Disk stats
|
||
diskInfo, err := disk.Usage("/")
|
||
if err != nil {
|
||
logger.Warning("get disk usage failed:", err)
|
||
} else {
|
||
status.Disk.Current = diskInfo.Used
|
||
status.Disk.Total = diskInfo.Total
|
||
}
|
||
|
||
// Load averages
|
||
avgState, err := load.Avg()
|
||
if err != nil {
|
||
logger.Warning("get load avg failed:", err)
|
||
} else {
|
||
status.Loads = []float64{avgState.Load1, avgState.Load5, avgState.Load15}
|
||
}
|
||
|
||
// Network stats
|
||
ioStats, err := net.IOCounters(false)
|
||
if err != nil {
|
||
logger.Warning("get io counters failed:", err)
|
||
} else if len(ioStats) > 0 {
|
||
ioStat := ioStats[0]
|
||
status.NetTraffic.Sent = ioStat.BytesSent
|
||
status.NetTraffic.Recv = ioStat.BytesRecv
|
||
|
||
if lastStatus != nil {
|
||
duration := now.Sub(lastStatus.T)
|
||
seconds := float64(duration) / float64(time.Second)
|
||
up := uint64(float64(status.NetTraffic.Sent-lastStatus.NetTraffic.Sent) / seconds)
|
||
down := uint64(float64(status.NetTraffic.Recv-lastStatus.NetTraffic.Recv) / seconds)
|
||
status.NetIO.Up = up
|
||
status.NetIO.Down = down
|
||
}
|
||
} else {
|
||
logger.Warning("can not find io counters")
|
||
}
|
||
|
||
// TCP/UDP connections
|
||
status.TcpCount, err = sys.GetTCPCount()
|
||
if err != nil {
|
||
logger.Warning("get tcp connections failed:", err)
|
||
}
|
||
|
||
status.UdpCount, err = sys.GetUDPCount()
|
||
if err != nil {
|
||
logger.Warning("get udp connections failed:", err)
|
||
}
|
||
|
||
// IP fetching with caching
|
||
showIp4ServiceLists := []string{
|
||
"https://api4.ipify.org",
|
||
"https://ipv4.icanhazip.com",
|
||
"https://v4.api.ipinfo.io/ip",
|
||
"https://ipv4.myexternalip.com/raw",
|
||
"https://4.ident.me",
|
||
"https://check-host.net/ip",
|
||
}
|
||
showIp6ServiceLists := []string{
|
||
"https://api6.ipify.org",
|
||
"https://ipv6.icanhazip.com",
|
||
"https://v6.api.ipinfo.io/ip",
|
||
"https://ipv6.myexternalip.com/raw",
|
||
"https://6.ident.me",
|
||
}
|
||
|
||
if s.cachedIPv4 == "" {
|
||
for _, ip4Service := range showIp4ServiceLists {
|
||
s.cachedIPv4 = getPublicIP(ip4Service)
|
||
if s.cachedIPv4 != "N/A" {
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
if s.cachedIPv6 == "" && !s.noIPv6 {
|
||
for _, ip6Service := range showIp6ServiceLists {
|
||
s.cachedIPv6 = getPublicIP(ip6Service)
|
||
if s.cachedIPv6 != "N/A" {
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
if s.cachedIPv6 == "N/A" {
|
||
s.noIPv6 = true
|
||
}
|
||
|
||
status.PublicIP.IPv4 = s.cachedIPv4
|
||
status.PublicIP.IPv6 = s.cachedIPv6
|
||
|
||
// Xray status
|
||
if s.xrayService.IsXrayRunning() {
|
||
status.Xray.State = Running
|
||
status.Xray.ErrorMsg = ""
|
||
} else {
|
||
err := s.xrayService.GetXrayErr()
|
||
if err != nil {
|
||
status.Xray.State = Error
|
||
} else {
|
||
status.Xray.State = Stop
|
||
}
|
||
status.Xray.ErrorMsg = s.xrayService.GetXrayResult()
|
||
}
|
||
status.Xray.Version = s.xrayService.GetXrayVersion()
|
||
|
||
// Application stats
|
||
var rtm runtime.MemStats
|
||
runtime.ReadMemStats(&rtm)
|
||
status.AppStats.Mem = rtm.Sys
|
||
status.AppStats.Threads = uint32(runtime.NumGoroutine())
|
||
if p != nil && p.IsRunning() {
|
||
status.AppStats.Uptime = p.GetUptime()
|
||
} else {
|
||
status.AppStats.Uptime = 0
|
||
}
|
||
|
||
return status
|
||
}
|
||
|
||
func (s *ServerService) GetXrayVersions() ([]string, error) {
|
||
const (
|
||
XrayURL = "https://api.github.com/repos/XTLS/Xray-core/releases"
|
||
bufferSize = 8192
|
||
)
|
||
|
||
resp, err := http.Get(XrayURL)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
buffer := bytes.NewBuffer(make([]byte, bufferSize))
|
||
buffer.Reset()
|
||
if _, err := buffer.ReadFrom(resp.Body); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
var releases []Release
|
||
if err := json.Unmarshal(buffer.Bytes(), &releases); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
var versions []string
|
||
for _, release := range releases {
|
||
tagVersion := strings.TrimPrefix(release.TagName, "v")
|
||
tagParts := strings.Split(tagVersion, ".")
|
||
if len(tagParts) != 3 {
|
||
continue
|
||
}
|
||
|
||
major, err1 := strconv.Atoi(tagParts[0])
|
||
minor, err2 := strconv.Atoi(tagParts[1])
|
||
patch, err3 := strconv.Atoi(tagParts[2])
|
||
if err1 != nil || err2 != nil || err3 != nil {
|
||
continue
|
||
}
|
||
|
||
if major > 25 || (major == 25 && minor > 9) || (major == 25 && minor == 9 && patch >= 10) {
|
||
versions = append(versions, release.TagName)
|
||
}
|
||
}
|
||
return versions, nil
|
||
}
|
||
|
||
func (s *ServerService) StopXrayService() error {
|
||
err := s.xrayService.StopXray()
|
||
if err != nil {
|
||
logger.Error("stop xray failed:", err)
|
||
return err
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *ServerService) RestartXrayService() error {
|
||
err := s.xrayService.RestartXray(true)
|
||
if err != nil {
|
||
logger.Error("start xray failed:", err)
|
||
return err
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *ServerService) downloadXRay(version string) (string, error) {
|
||
osName := runtime.GOOS
|
||
arch := runtime.GOARCH
|
||
|
||
switch osName {
|
||
case "darwin":
|
||
osName = "macos"
|
||
case "windows":
|
||
osName = "windows"
|
||
}
|
||
|
||
switch arch {
|
||
case "amd64":
|
||
arch = "64"
|
||
case "arm64":
|
||
arch = "arm64-v8a"
|
||
case "armv7":
|
||
arch = "arm32-v7a"
|
||
case "armv6":
|
||
arch = "arm32-v6"
|
||
case "armv5":
|
||
arch = "arm32-v5"
|
||
case "386":
|
||
arch = "32"
|
||
case "s390x":
|
||
arch = "s390x"
|
||
}
|
||
|
||
fileName := fmt.Sprintf("Xray-%s-%s.zip", osName, arch)
|
||
url := fmt.Sprintf("https://github.com/XTLS/Xray-core/releases/download/%s/%s", version, fileName)
|
||
resp, err := http.Get(url)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
os.Remove(fileName)
|
||
file, err := os.Create(fileName)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
defer file.Close()
|
||
|
||
_, err = io.Copy(file, resp.Body)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
|
||
return fileName, nil
|
||
}
|
||
|
||
func (s *ServerService) UpdateXray(version string) error {
|
||
// 1. Stop xray before doing anything
|
||
if err := s.StopXrayService(); err != nil {
|
||
logger.Warning("failed to stop xray before update:", err)
|
||
}
|
||
|
||
// 2. Download the zip
|
||
zipFileName, err := s.downloadXRay(version)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer os.Remove(zipFileName)
|
||
|
||
zipFile, err := os.Open(zipFileName)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer zipFile.Close()
|
||
|
||
stat, err := zipFile.Stat()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
reader, err := zip.NewReader(zipFile, stat.Size())
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// 3. Helper to extract files
|
||
|
||
copyZipFile := func(zipName string, fileName string) error {
|
||
zipFile, err := reader.Open(zipName)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer zipFile.Close()
|
||
os.MkdirAll(filepath.Dir(fileName), 0755)
|
||
os.Remove(fileName)
|
||
file, err := os.OpenFile(fileName, os.O_CREATE|os.O_RDWR|os.O_TRUNC, fs.ModePerm)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer file.Close()
|
||
_, err = io.Copy(file, zipFile)
|
||
return err
|
||
}
|
||
|
||
// 4. Extract correct binary
|
||
if runtime.GOOS == "windows" {
|
||
targetBinary := filepath.Join("bin", "xray-windows-amd64.exe")
|
||
err = copyZipFile("xray.exe", targetBinary)
|
||
} else {
|
||
err = copyZipFile("xray", xray.GetBinaryPath())
|
||
}
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// 5. Restart xray
|
||
if err := s.xrayService.RestartXray(true); err != nil {
|
||
logger.Error("start xray failed:", err)
|
||
return err
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func (s *ServerService) GetLogs(count string, level string, syslog string) []string {
|
||
c, _ := strconv.Atoi(count)
|
||
var lines []string
|
||
|
||
if syslog == "true" {
|
||
cmdArgs := []string{"journalctl", "-u", "x-ui", "--no-pager", "-n", count, "-p", level}
|
||
// Run the command
|
||
cmd := exec.Command(cmdArgs[0], cmdArgs[1:]...)
|
||
var out bytes.Buffer
|
||
cmd.Stdout = &out
|
||
err := cmd.Run()
|
||
if err != nil {
|
||
return []string{"Failed to run journalctl command!"}
|
||
}
|
||
lines = strings.Split(out.String(), "\n")
|
||
} else {
|
||
lines = logger.GetLogs(c, level)
|
||
}
|
||
|
||
return lines
|
||
}
|
||
|
||
func (s *ServerService) GetXrayLogs(
|
||
count string,
|
||
filter string,
|
||
showDirect string,
|
||
showBlocked string,
|
||
showProxy string,
|
||
freedoms []string,
|
||
blackholes []string) []string {
|
||
|
||
countInt, _ := strconv.Atoi(count)
|
||
var lines []string
|
||
|
||
pathToAccessLog, err := xray.GetAccessLogPath()
|
||
if err != nil {
|
||
return lines
|
||
}
|
||
|
||
file, err := os.Open(pathToAccessLog)
|
||
if err != nil {
|
||
return lines
|
||
}
|
||
defer file.Close()
|
||
|
||
scanner := bufio.NewScanner(file)
|
||
|
||
for scanner.Scan() {
|
||
line := strings.TrimSpace(scanner.Text())
|
||
|
||
if line == "" || strings.Contains(line, "api -> api") {
|
||
//skipping empty lines and api calls
|
||
continue
|
||
}
|
||
|
||
if filter != "" && !strings.Contains(line, filter) {
|
||
//applying filter if it's not empty
|
||
continue
|
||
}
|
||
|
||
//adding suffixes to further distinguish entries by outbound
|
||
if hasSuffix(line, freedoms) {
|
||
if showDirect == "false" {
|
||
continue
|
||
}
|
||
line = line + " f"
|
||
} else if hasSuffix(line, blackholes) {
|
||
if showBlocked == "false" {
|
||
continue
|
||
}
|
||
line = line + " b"
|
||
} else {
|
||
if showProxy == "false" {
|
||
continue
|
||
}
|
||
line = line + " p"
|
||
}
|
||
|
||
lines = append(lines, line)
|
||
}
|
||
|
||
if len(lines) > countInt {
|
||
lines = lines[len(lines)-countInt:]
|
||
}
|
||
|
||
return lines
|
||
}
|
||
|
||
func hasSuffix(line string, suffixes []string) bool {
|
||
for _, sfx := range suffixes {
|
||
if strings.HasSuffix(line, sfx+"]") {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func (s *ServerService) GetConfigJson() (any, error) {
|
||
config, err := s.xrayService.GetXrayConfig()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// 修复:将 U+00A0 替换为标准空格
|
||
contents, err := json.MarshalIndent(config, "", " ")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
var jsonData any
|
||
err = json.Unmarshal(contents, &jsonData)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return jsonData, nil
|
||
}
|
||
|
||
func (s *ServerService) GetDb() ([]byte, error) {
|
||
// Update by manually trigger a checkpoint operation
|
||
err := database.Checkpoint()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// Open the file for reading
|
||
file, err := os.Open(config.GetDBPath())
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer file.Close()
|
||
|
||
// Read the file contents
|
||
fileContents, err := io.ReadAll(file)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return fileContents, nil
|
||
}
|
||
|
||
func (s *ServerService) ImportDB(file multipart.File) error {
|
||
// Check if the file is a SQLite database
|
||
isValidDb, err := database.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 a temporary file
|
||
tempPath := fmt.Sprintf("%s.temp", config.GetDBPath())
|
||
|
||
// Remove the existing temporary file (if any)
|
||
if _, err := os.Stat(tempPath); err == nil {
|
||
if errRemove := os.Remove(tempPath); 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)
|
||
}
|
||
|
||
// Robust deferred cleanup for the temporary file
|
||
defer func() {
|
||
if tempFile != nil {
|
||
if cerr := tempFile.Close(); cerr != nil {
|
||
logger.Warningf("Warning: failed to close temp file: %v", cerr)
|
||
}
|
||
}
|
||
if _, err := os.Stat(tempPath); err == nil {
|
||
if rerr := os.Remove(tempPath); rerr != nil {
|
||
logger.Warningf("Warning: failed to remove temp file: %v", rerr)
|
||
}
|
||
}
|
||
}()
|
||
|
||
// Save uploaded file to temporary file
|
||
if _, err = io.Copy(tempFile, file); err != nil {
|
||
return common.NewErrorf("Error saving db: %v", err)
|
||
}
|
||
|
||
// Check if we can init the db or not
|
||
if err = database.InitDB(tempPath); err != nil {
|
||
return common.NewErrorf("Error checking db: %v", err)
|
||
}
|
||
|
||
// Stop Xray
|
||
s.StopXrayService()
|
||
|
||
// Backup the current database for fallback
|
||
fallbackPath := fmt.Sprintf("%s.backup", config.GetDBPath())
|
||
|
||
// Remove the existing fallback file (if any)
|
||
if _, err := os.Stat(fallbackPath); err == nil {
|
||
if errRemove := os.Remove(fallbackPath); errRemove != nil {
|
||
return common.NewErrorf("Error removing existing fallback db file: %v", errRemove)
|
||
}
|
||
}
|
||
|
||
// Move the current database to the fallback location
|
||
if err = os.Rename(config.GetDBPath(), fallbackPath); err != nil {
|
||
return common.NewErrorf("Error backing up current db file: %v", err)
|
||
}
|
||
|
||
// Defer fallback cleanup ONLY if everything goes well
|
||
defer func() {
|
||
if _, err := os.Stat(fallbackPath); err == nil {
|
||
if rerr := os.Remove(fallbackPath); rerr != nil {
|
||
logger.Warningf("Warning: failed to remove fallback file: %v", rerr)
|
||
}
|
||
}
|
||
}()
|
||
|
||
// Move temp to DB path
|
||
if err = os.Rename(tempPath, config.GetDBPath()); err != nil {
|
||
// Restore from fallback
|
||
if errRename := os.Rename(fallbackPath, config.GetDBPath()); 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
|
||
if err = database.InitDB(config.GetDBPath()); err != nil {
|
||
if errRename := os.Rename(fallbackPath, config.GetDBPath()); errRename != nil {
|
||
return common.NewErrorf("Error migrating db and restoring fallback: %v", errRename)
|
||
}
|
||
return common.NewErrorf("Error migrating db: %v", err)
|
||
}
|
||
|
||
s.inboundService.MigrateDB()
|
||
|
||
// Start Xray
|
||
if err = s.RestartXrayService(); err != nil {
|
||
return common.NewErrorf("Imported DB but failed to start Xray: %v", err)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func (s *ServerService) UpdateGeofile(fileName string) error {
|
||
files := []struct {
|
||
URL string
|
||
FileName string
|
||
}{
|
||
{"https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat", "geoip.dat"},
|
||
{"https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat", "geosite.dat"},
|
||
{"https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat", "geoip_IR.dat"},
|
||
{"https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geosite.dat", "geosite_IR.dat"},
|
||
{"https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat", "geoip_RU.dat"},
|
||
{"https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat", "geosite_RU.dat"},
|
||
}
|
||
|
||
downloadFile := func(url, destPath string) error {
|
||
resp, err := http.Get(url)
|
||
if err != nil {
|
||
return common.NewErrorf("Failed to download Geofile from %s: %v", url, err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
file, err := os.Create(destPath)
|
||
if err != nil {
|
||
return common.NewErrorf("Failed to create Geofile %s: %v", destPath, err)
|
||
}
|
||
defer file.Close()
|
||
|
||
_, err = io.Copy(file, resp.Body)
|
||
if err != nil {
|
||
return common.NewErrorf("Failed to save Geofile %s: %v", destPath, err)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
var errorMessages []string
|
||
|
||
if fileName == "" {
|
||
for _, file := range files {
|
||
destPath := fmt.Sprintf("%s/%s", config.GetBinFolderPath(), file.FileName)
|
||
|
||
if err := downloadFile(file.URL, destPath); err != nil {
|
||
errorMessages = append(errorMessages, fmt.Sprintf("Error downloading Geofile '%s': %v", file.FileName, err))
|
||
}
|
||
}
|
||
} else {
|
||
destPath := fmt.Sprintf("%s/%s", config.GetBinFolderPath(), fileName)
|
||
|
||
var fileURL string
|
||
for _, file := range files {
|
||
if file.FileName == fileName {
|
||
fileURL = file.URL
|
||
break
|
||
}
|
||
}
|
||
|
||
if fileURL == "" {
|
||
errorMessages = append(errorMessages, fmt.Sprintf("File '%s' not found in the list of Geofiles", fileName))
|
||
}
|
||
|
||
if err := downloadFile(fileURL, destPath); err != nil {
|
||
errorMessages = append(errorMessages, fmt.Sprintf("Error downloading Geofile '%s': %v", fileName, err))
|
||
}
|
||
}
|
||
|
||
err := s.RestartXrayService()
|
||
if err != nil {
|
||
errorMessages = append(errorMessages, fmt.Sprintf("Updated Geofile '%s' but Failed to start Xray: %v", fileName, err))
|
||
}
|
||
|
||
if len(errorMessages) > 0 {
|
||
return common.NewErrorf("%s", strings.Join(errorMessages, "\r\n"))
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func (s *ServerService) GetNewX25519Cert() (any, error) {
|
||
// Run the command
|
||
cmd := exec.Command(xray.GetBinaryPath(), "x25519")
|
||
var out bytes.Buffer
|
||
cmd.Stdout = &out
|
||
err := cmd.Run()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
lines := strings.Split(out.String(), "\n")
|
||
|
||
privateKeyLine := strings.Split(lines[0], ":")
|
||
publicKeyLine := strings.Split(lines[1], ":")
|
||
|
||
privateKey := strings.TrimSpace(privateKeyLine[1])
|
||
publicKey := strings.TrimSpace(publicKeyLine[1])
|
||
|
||
keyPair := map[string]any{
|
||
"privateKey": privateKey,
|
||
"publicKey": publicKey, // 修复:U+00A0 替换为标准空格
|
||
}
|
||
|
||
return keyPair, nil
|
||
}
|
||
|
||
func (s *ServerService) GetNewmldsa65() (any, error) {
|
||
// Run the command
|
||
cmd := exec.Command(xray.GetBinaryPath(), "mldsa65")
|
||
var out bytes.Buffer
|
||
cmd.Stdout = &out
|
||
err := cmd.Run()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
lines := strings.Split(out.String(), "\n")
|
||
|
||
SeedLine := strings.Split(lines[0], ":")
|
||
VerifyLine := strings.Split(lines[1], ":")
|
||
|
||
seed := strings.TrimSpace(SeedLine[1])
|
||
verify := strings.TrimSpace(VerifyLine[1])
|
||
|
||
keyPair := map[string]any{
|
||
"seed": seed,
|
||
"verify": verify,
|
||
}
|
||
|
||
return keyPair, nil
|
||
}
|
||
|
||
func (s *ServerService) GetNewEchCert(sni string) (interface{}, error) {
|
||
// Run the command
|
||
cmd := exec.Command(xray.GetBinaryPath(), "tls", "ech", "--serverName", sni)
|
||
var out bytes.Buffer
|
||
cmd.Stdout = &out
|
||
err := cmd.Run()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
lines := strings.Split(out.String(), "\n")
|
||
if len(lines) < 4 {
|
||
return nil, common.NewError("invalid ech cert")
|
||
}
|
||
|
||
configList := lines[1]
|
||
serverKeys := lines[3]
|
||
|
||
return map[string]interface{}{
|
||
"echServerKeys": serverKeys,
|
||
"echConfigList": configList,
|
||
}, nil
|
||
}
|
||
|
||
func (s *ServerService) GetNewVlessEnc() (any, error) {
|
||
cmd := exec.Command(xray.GetBinaryPath(), "vlessenc")
|
||
var out bytes.Buffer
|
||
cmd.Stdout = &out
|
||
if err := cmd.Run(); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
lines := strings.Split(out.String(), "\n")
|
||
|
||
var auths []map[string]string
|
||
var current map[string]string
|
||
|
||
for _, line := range lines {
|
||
line = strings.TrimSpace(line)
|
||
if strings.HasPrefix(line, "Authentication:") {
|
||
if current != nil {
|
||
auths = append(auths, current)
|
||
}
|
||
current = map[string]string{
|
||
"label": strings.TrimSpace(strings.TrimPrefix(line, "Authentication:")),
|
||
}
|
||
} else if strings.HasPrefix(line, `"decryption"`) || strings.HasPrefix(line, `"encryption"`) {
|
||
parts := strings.SplitN(line, ":", 2)
|
||
if len(parts) == 2 && current != nil {
|
||
key := strings.Trim(parts[0], `" `)
|
||
val := strings.Trim(parts[1], `" `)
|
||
current[key] = val
|
||
}
|
||
}
|
||
}
|
||
|
||
if current != nil {
|
||
auths = append(auths, current)
|
||
}
|
||
|
||
return map[string]any{
|
||
"auths": auths,
|
||
}, nil
|
||
}
|
||
|
||
func (s *ServerService) GetNewUUID() (map[string]string, error) {
|
||
newUUID, err := uuid.NewRandom()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("failed to generate UUID: %w", err)
|
||
}
|
||
|
||
return map[string]string{
|
||
"uuid": newUUID.String(),
|
||
}, nil
|
||
}
|
||
|
||
func (s *ServerService) GetNewmlkem768() (any, error) {
|
||
// Run the command
|
||
cmd := exec.Command(xray.GetBinaryPath(), "mlkem768")
|
||
var out bytes.Buffer
|
||
cmd.Stdout = &out
|
||
err := cmd.Run()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
lines := strings.Split(out.String(), "\n")
|
||
|
||
SeedLine := strings.Split(lines[0], ":")
|
||
ClientLine := strings.Split(lines[1], ":")
|
||
|
||
seed := strings.TrimSpace(SeedLine[1])
|
||
client := strings.TrimSpace(ClientLine[1])
|
||
|
||
keyPair := map[string]any{
|
||
"seed": seed,
|
||
"client": client,
|
||
}
|
||
|
||
return keyPair, nil
|
||
}
|
||
|
||
// SaveLinkHistory 保存一个新的链接记录,并确保其被永久写入数据库文件。
|
||
func (s *ServerService) SaveLinkHistory(historyType, link string) error {
|
||
record := &database.LinkHistory{
|
||
Type: historyType,
|
||
Link: link,
|
||
CreatedAt: time.Now(),
|
||
}
|
||
|
||
// 【核心修正】: 第一步,调用重构后的 AddLinkHistory 函数。
|
||
// 这个函数现在是一个原子事务。如果它没有返回错误,就意味着数据已经成功提交到了 .wal 日志文件。
|
||
err := database.AddLinkHistory(record)
|
||
if err != nil {
|
||
return err // 如果事务失败,直接返回错误,不执行后续操作
|
||
}
|
||
|
||
// 【核心修正】: 第二步,在事务成功提交后,我们在这里调用 Checkpoint。
|
||
// 此时 .wal 文件中已经包含了我们的新数据,调用 Checkpoint 可以确保这些数据被立即写入主数据库文件。
|
||
return database.Checkpoint()
|
||
}
|
||
|
||
// LoadLinkHistory loads the latest 10 links from the database
|
||
func (s *ServerService) LoadLinkHistory() ([]*database.LinkHistory, error) {
|
||
return database.GetLinkHistory()
|
||
}
|
||
|
||
// 〔新增方法〕: 安装 Subconverter (异步执行)
|
||
// 〔中文注释〕: 此方法用于接收前端或 TG 的请求,并执行 x-ui.sh 脚本中的 subconverter 函数
|
||
func (s *ServerService) InstallSubconverter() error {
|
||
// 〔中文注释〕: 使用一个新的 goroutine 来执行耗时的安装任务,这样 API 可以立即返回
|
||
go func() {
|
||
|
||
// 【新增功能】:执行端口放行操作
|
||
var ufwWarning string
|
||
if ufwErr := s.openSubconverterPorts(); ufwErr != nil {
|
||
// 不中断流程,只生成警告消息
|
||
logger.Warningf("自动放行 Subconverter 端口失败: %v", ufwErr)
|
||
ufwWarning = fmt.Sprintf("⚠️ **警告:订阅转换端口放行失败**\n\n自动执行 UFW 命令失败,请务必**手动**在您的 VPS 上放行端口 `8000` 和 `15268`,否则服务将无法访问。失败详情:%v\n\n", ufwErr)
|
||
}
|
||
|
||
// 〔中文注释〕: 检查全局的 TgBot 实例是否存在并且正在运行
|
||
if s.tgService == nil || !s.tgService.IsRunning() {
|
||
logger.Warning("TgBot 未运行,无法发送【订阅转换】状态通知。")
|
||
// 即使机器人未运行,安装流程也应继续,只是不发通知
|
||
ufwWarning = "" // 如果机器人不在线,不发送任何警告/消息
|
||
}
|
||
|
||
// 脚本路径为 /usr/bin/x-ui
|
||
// 〔中文注释〕: 通常,安装脚本会将主命令软链接或复制到 /usr/bin/ 目录下,使其成为一个系统命令。
|
||
// 直接调用这个命令比调用源文件路径更规范,也能确保执行的是用户在命令行中使用的同一个脚本。
|
||
scriptPath := "/usr/bin/x-ui"
|
||
|
||
// 〔中文注释〕: 检查脚本文件是否存在
|
||
if _, err := os.Stat(scriptPath); os.IsNotExist(err) {
|
||
errMsg := fmt.Sprintf("订阅转换安装失败:关键脚本文件 `%s` 未找到。", scriptPath)
|
||
logger.Error(errMsg)
|
||
if s.tgService != nil && s.tgService.IsRunning() {
|
||
// 〔中文注释〕: 使用 Markdown 格式发送错误消息
|
||
s.tgService.SendMessage("❌ " + errMsg)
|
||
}
|
||
return
|
||
}
|
||
|
||
// 〔中文注释〕: 正确的调用方式是:命令是 "x-ui",参数是 "subconverter"。
|
||
cmd := exec.Command(scriptPath, "subconverter")
|
||
|
||
// 〔中文注释〕: 执行命令并获取其合并的输出(标准输出 + 标准错误),方便排查问题。
|
||
// 〔重要〕: 这个命令可能需要几分钟才能执行完毕,Go程序会在此等待直到脚本执行完成。
|
||
output, err := cmd.CombinedOutput()
|
||
|
||
if err != nil {
|
||
if s.tgService != nil && s.tgService.IsRunning() {
|
||
// 构造失败消息
|
||
message := fmt.Sprintf("❌ **订阅转换安装失败**!\n\n**错误信息**: %v\n**输出**: %s", err, string(output))
|
||
s.tgService.SendMessage(message)
|
||
}
|
||
logger.Errorf("订阅转换安装失败: %v\n输出: %s", err, string(output))
|
||
return
|
||
} else {
|
||
|
||
// 【新增逻辑】:如果之前端口放行失败,先发送警告消息
|
||
if ufwWarning != "" {
|
||
s.tgService.SendMessage(ufwWarning)
|
||
}
|
||
|
||
// 安装成功后,发送通知到 TG 机器人
|
||
if s.tgService != nil && s.tgService.IsRunning() {
|
||
// 获取面板域名,注意:t.getDomain() 是 Tgbot 的方法
|
||
domain, getDomainErr := s.tgService.GetDomain()
|
||
if getDomainErr != nil {
|
||
logger.Errorf("TG Bot: 订阅转换安装成功,但获取域名失败: %v", getDomainErr)
|
||
} else {
|
||
// 构造消息,使用用户指定的格式
|
||
message := fmt.Sprintf(
|
||
"🎉 **恭喜!【订阅转换】模块已成功安装!**\n\n"+
|
||
"您现在可以使用以下地址访问 Web 界面:\n\n"+
|
||
"🔗 **登录地址**: `https://%s:15268`\n\n"+
|
||
"默认用户名: `admin`\n"+
|
||
"默认 密码: `123456`\n\n"+
|
||
"可登录订阅转换后台修改您的密码!", domain)
|
||
|
||
// 发送成功消息
|
||
if sendErr := s.tgService.SendMessage(message); sendErr != nil {
|
||
logger.Errorf("TG Bot: 订阅转换安装成功,但发送通知失败: %v", sendErr)
|
||
} else {
|
||
logger.Info("TG Bot: 订阅转换安装成功通知已发送。")
|
||
}
|
||
}
|
||
}
|
||
|
||
logger.Info("订阅转换安装成功。")
|
||
return
|
||
}
|
||
}()
|
||
|
||
return nil // 立即返回,表示指令已接收
|
||
}
|
||
|
||
// openSubconverterPorts 检查/安装 ufw 并放行 8000 和 15268 端口
|
||
func (s *ServerService) openSubconverterPorts() error {
|
||
// Shell 脚本:检查/安装 UFW,然后循环放行 8000 和 15268 端口,最后尝试激活
|
||
shellCommand := `
|
||
PORTS_TO_OPEN="8000 15268"
|
||
|
||
echo "正在为订阅转换自动检查并放行端口 $PORTS_TO_OPEN"
|
||
|
||
# 1. 检查/安装 ufw
|
||
if ! command -v ufw &>/dev/null; then
|
||
echo "ufw 防火墙未安装,正在安装..."
|
||
# 静默更新和安装
|
||
DEBIAN_FRONTEND=noninteractive /usr/bin/apt-get update -qq >/dev/null
|
||
DEBIAN_FRONTEND=noninteractive /usr/bin/apt-get install -y ufw
|
||
if [ $? -ne 0 ]; then echo "❌ ufw 安装失败或权限不足。"; exit 1; fi
|
||
fi
|
||
|
||
# 2. 放行端口
|
||
for port in $PORTS_TO_OPEN; do
|
||
echo "正在执行 ufw allow $port..."
|
||
ufw allow $port
|
||
if [ $? -ne 0 ]; then echo "❌ ufw 端口 $port 放行失败。"; exit 1; fi
|
||
done
|
||
|
||
# 3. 检查/激活防火墙
|
||
if ! ufw status | grep -q "Status: active"; then
|
||
echo "ufw 状态:未激活。正在尝试激活..."
|
||
ufw --force enable
|
||
if [ $? -ne 0 ]; then echo "❌ ufw 激活失败。"; exit 1; fi
|
||
fi
|
||
|
||
echo "✅ 端口 $PORTS_TO_OPEN 已成功放行/检查。"
|
||
exit 0
|
||
`
|
||
|
||
// 使用 /bin/bash -c 执行命令,并捕获输出
|
||
cmd := exec.CommandContext(context.Background(), "/bin/bash", "-c", shellCommand)
|
||
output, err := cmd.CombinedOutput()
|
||
logOutput := string(output)
|
||
|
||
// 记录日志,无论成功与否
|
||
logger.Infof("执行 Subconverter 端口放行命令结果:\n%s", logOutput)
|
||
|
||
if err != nil {
|
||
// 如果 Shell 命令返回非零退出码,则返回错误
|
||
return fmt.Errorf("ufw 端口放行失败: %v. 脚本输出: %s", err, logOutput)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// 【新增方法实现】: 后台前端开放指定端口
|
||
// OpenPort 供前端调用,自动检查/安装 ufw 并放行指定的端口。
|
||
// 〔中文注释〕: 整个函数逻辑被放入一个 go func() 协程中,实现异步后台执行。
|
||
// 〔中文注释〕: 函数签名不再返回 error,因为它会立即返回,无法得知后台任务的最终结果。
|
||
func (s *ServerService) OpenPort(port string) {
|
||
// 〔中文注释〕: 启动一个新的协程来处理耗时任务,这样 HTTP 请求可以立刻返回。
|
||
go func() {
|
||
// 1. 将 port string 转换为 int
|
||
portInt, err := strconv.Atoi(port)
|
||
if err != nil {
|
||
// 〔中文注释〕: 在后台任务中,如果出错,我们只能记录日志,因为无法再返回给前端。
|
||
logger.Errorf("端口号格式错误,无法转换为数字: %s", port)
|
||
return
|
||
}
|
||
|
||
// 2. 将 Shell 逻辑整合为一个可执行的命令,并使用 /bin/bash -c 执行
|
||
shellCommand := fmt.Sprintf(`
|
||
PORT_TO_OPEN=%d
|
||
|
||
echo "正在为入站配置自动检查并放行端口 $PORT_TO_OPEN"
|
||
|
||
# 1. 检查/安装 ufw (仅限 Debian/Ubuntu 系统)
|
||
if ! command -v ufw &>/dev/null; then
|
||
echo "ufw 防火墙未安装,正在安装..."
|
||
# 使用绝对路径执行 apt-get,避免 PATH 问题
|
||
DEBIAN_FRONTEND=noninteractive /usr/bin/apt-get update -qq >/dev/null
|
||
DEBIAN_FRONTEND=noninteractive /usr/bin/apt-get install -y ufw
|
||
if [ $? -ne 0 ]; then echo "❌ ufw 安装失败,可能不是 Debian/Ubuntu 系统,或者权限不足。"; exit 1; fi
|
||
fi
|
||
|
||
# 2. 放行端口 (TCP/UDP)
|
||
echo "正在执行 ufw allow $PORT_TO_OPEN..."
|
||
ufw allow $PORT_TO_OPEN
|
||
if [ $? -ne 0 ]; then echo "❌ ufw 端口 $PORT_TO_OPEN 放行失败。"; exit 1; fi
|
||
|
||
# 3. 检查/激活防火墙
|
||
if ! ufw status | grep -q "Status: active"; then
|
||
echo "ufw 状态:未激活。正在尝试激活..."
|
||
ufw --force enable
|
||
if [ $? -ne 0 ]; then echo "❌ ufw 激活失败。"; exit 1; fi
|
||
fi
|
||
echo "✅ 端口 $PORT_TO_OPEN 已成功放行/检查。"
|
||
`, portInt) // 使用转换后的 portInt
|
||
|
||
// 3. 使用 exec.CommandContext 运行命令
|
||
// 添加 70 秒超时,防止命令挂起导致 HTTP 连接断开
|
||
ctx, cancel := context.WithTimeout(context.Background(), 70*time.Second)
|
||
defer cancel() // 确保 context 在函数退出时被取消
|
||
|
||
cmd := exec.CommandContext(ctx, "/bin/bash", "-c", shellCommand)
|
||
|
||
// 4. 捕获命令的输出
|
||
output, err := cmd.CombinedOutput()
|
||
|
||
// 5. 记录日志,以便诊断
|
||
logOutput := strings.TrimSpace(string(output))
|
||
logger.Infof("执行 ufw 端口放行命令(端口 %s)结果:\n%s", port, logOutput)
|
||
|
||
// 〔中文注释〕: 这里的错误处理现在只用于在后台记录日志。
|
||
if err != nil {
|
||
errorMsg := fmt.Sprintf("后台执行端口 %s 自动放行失败。错误: %v", port, err)
|
||
logger.Error(errorMsg)
|
||
// 〔可选〕: 未来可以在这里加入 Telegram 机器人通知等功能,来通知管理员任务失败。
|
||
}
|
||
}()
|
||
}
|