Y-Panel/web/service/server.go

1211 lines
32 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 机器人通知等功能,来通知管理员任务失败。
}
}()
}