- service/xray.go 1741->573行: 只剩 agent 配置构建(GetXrayConfigForAgent/buildAgentXrayConfig=模板+节点入站+egress+转发编译); 删进程生命周期/状态机/RestartXray/StopXray/SetToNeedRestart/GetXrayTraffic/GetOnlineUsers/balancer/TestRoute 与 injectPanelEgress/injectNodeEgresses/injectMtproto/injectAmneziawg/mergeSubscriptionOutbounds
- controller: 43处 SetToNeedRestart 与各 RestartXray 调用清除; 删 balancerStatus/balancerOverride/routeTest/getXrayResult 路由与handler(纯本地核心操作); 前端删 RouteTester 页签与组件
- server service: GetConfigJson 改模板直读; 状态Xray块恒 stopped/空版本; Stop/RestartXrayService 改 no-op(DB导入/恢复流程保留形状)
- setting service: PanelEgressProxyURL/NodeEgressProxyURL 恒直连(桥在本地核心内,已不存在); NodeService.withOutboundBridge 恒直连回退
- runtime: Local 改为全 no-op 桩(DB即真源); NewManager() 去 LocalDeps(APIPort/SetNeedRestart)
- job: 删 XrayTrafficJob/CheckXrayRunningJob/CheckClientIpJob 三文件(共享声明 EventBus/IPWithTimestamp/clientStatsSnapshotMaxClients 迁 shared.go); web.go 不再调度 MtprotoJob/AmneziaWGJob, 删 OnCrash 接线与 stop() 的 sidecar StopAll
- MTProto/AmneziaWG 下线: AddInbound/UpdateInbound 对两协议直接拒绝(存量行保留可删); TG bot 的 restart 命令与在线查询按无本地核心收口
- 测试: GetXrayConfig 系列移植到 agent 构建(finalmask/clients/wireguard/scale); 删退役行为测试(lifecycle/restart/mtproto本地应用/client-ip job/inject系列); 共享测试helper迁 mtproto_test_helpers_test.go 与 job/integration_helpers_test.go
- 既有红(非本轮引入,已在 f6d6520 复现): TestGetXrayConfig_EnabledClientsStillEmitted、TestRouteRegistryContract(P6删 endpoints.ts 遗留)
1780 lines
54 KiB
Go
1780 lines
54 KiB
Go
package service
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/ecdh"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"mime/multipart"
|
|
stdnet "net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/util/sys"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
|
|
|
"github.com/google/uuid"
|
|
utls "github.com/refraction-networking/utls"
|
|
"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"
|
|
)
|
|
|
|
// ProcessState represents the current state of a system process.
|
|
type ProcessState string
|
|
|
|
// Process state constants
|
|
const (
|
|
Running ProcessState = "running" // Process is running normally
|
|
Stop ProcessState = "stop" // Process is stopped
|
|
Error ProcessState = "error" // Process is in error state
|
|
)
|
|
|
|
// Status represents comprehensive system and application status information.
|
|
// It includes CPU, memory, disk, network statistics, and Xray process status.
|
|
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"`
|
|
DiskIO struct {
|
|
Read uint64 `json:"read"`
|
|
Write uint64 `json:"write"`
|
|
} `json:"diskIO"`
|
|
DiskTraffic struct {
|
|
Read uint64 `json:"read"`
|
|
Write uint64 `json:"write"`
|
|
} `json:"diskTraffic"`
|
|
Xray struct {
|
|
State ProcessState `json:"state"`
|
|
ErrorMsg string `json:"errorMsg"`
|
|
Version string `json:"version"`
|
|
} `json:"xray"`
|
|
// AmneziaWG gates the overview's AmneziaWG log view: Configured stays true
|
|
// while an inbound exists but its embedded interface isn't up yet, which
|
|
// is exactly when that view's event lines are worth reading.
|
|
AmneziaWG struct {
|
|
Configured bool `json:"configured"`
|
|
Running bool `json:"running"`
|
|
} `json:"amneziawg"`
|
|
PanelVersion string `json:"panelVersion"`
|
|
PanelGuid string `json:"panelGuid"`
|
|
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"`
|
|
PktUp uint64 `json:"pktUp"`
|
|
PktDown uint64 `json:"pktDown"`
|
|
} `json:"netIO"`
|
|
NetTraffic struct {
|
|
Sent uint64 `json:"sent"`
|
|
Recv uint64 `json:"recv"`
|
|
PktSent uint64 `json:"pktSent"`
|
|
PktRecv uint64 `json:"pktRecv"`
|
|
} `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"`
|
|
}
|
|
|
|
// Release represents information about a software release from GitHub.
|
|
type Release struct {
|
|
TagName string `json:"tag_name"` // The tag name of the release
|
|
Body string `json:"body"` // The release notes; the dev channel reads its commit from here
|
|
TargetCommitish string `json:"target_commitish"` // The branch/commit the tag points at
|
|
Prerelease bool `json:"prerelease"` // Whether this is a pre-release
|
|
}
|
|
|
|
// ServerService provides business logic for server monitoring and management.
|
|
// It handles system status collection, IP detection, and application statistics.
|
|
type ServerService struct {
|
|
xrayService XrayService
|
|
inboundService InboundService
|
|
settingService SettingService
|
|
cachedIPv4 string
|
|
cachedIPv6 string
|
|
noIPv6 bool
|
|
mu sync.Mutex
|
|
lastCPUTimes cpu.TimesStat
|
|
hasLastCPUSample bool
|
|
hasNativeCPUSample bool
|
|
emaCPU float64
|
|
cachedCpuSpeedMhz float64
|
|
lastCpuInfoAttempt time.Time
|
|
|
|
lastStatusMu sync.RWMutex
|
|
lastStatus *Status
|
|
|
|
versionsCacheMu sync.Mutex
|
|
|
|
fail2banMu sync.Mutex
|
|
fail2banInstalled bool
|
|
fail2banCheckedAt time.Time
|
|
}
|
|
|
|
// allowedHistoryBuckets is the bucket-second whitelist for time-series
|
|
// aggregation endpoints (server + node metrics). Restricting it prevents
|
|
// callers from triggering arbitrary aggregation work and keeps the
|
|
// frontend's bucket selector self-documenting.
|
|
var allowedHistoryBuckets = map[int]bool{
|
|
2: true, // 2m
|
|
30: true, // 30m
|
|
60: true, // 1h
|
|
180: true, // 3h
|
|
360: true, // 6h
|
|
720: true, // 12h
|
|
1440: true, // 24h
|
|
2880: true, // 2d
|
|
10080: true, // 7d
|
|
}
|
|
|
|
// IsAllowedHistoryBucket reports whether a bucket-seconds value is in the
|
|
// whitelist used by the time-series aggregation endpoints
|
|
// (/server/history for the homepage overview, /nodes/history).
|
|
func IsAllowedHistoryBucket(bucketSeconds int) bool {
|
|
return allowedHistoryBuckets[bucketSeconds]
|
|
}
|
|
|
|
// LastStatus returns the most recent Status snapshot collected by
|
|
// RefreshStatus. Safe for concurrent readers.
|
|
func (s *ServerService) LastStatus() *Status {
|
|
s.lastStatusMu.RLock()
|
|
defer s.lastStatusMu.RUnlock()
|
|
return s.lastStatus
|
|
}
|
|
|
|
// Fail2banStatus tells the frontend whether the per-client IP limit can
|
|
// actually be enforced. Enforcement depends on fail2ban, so a limit set
|
|
// without it would silently do nothing.
|
|
type Fail2banStatus struct {
|
|
Enabled bool `json:"enabled"`
|
|
Installed bool `json:"installed"`
|
|
Usable bool `json:"usable"`
|
|
Windows bool `json:"windows"`
|
|
}
|
|
|
|
const fail2banInstalledCacheTTL = 30 * time.Second
|
|
|
|
func (s *ServerService) GetFail2banStatus() Fail2banStatus {
|
|
enabled := isFail2banEnabled()
|
|
|
|
installed := false
|
|
if enabled {
|
|
installed = s.isFail2banInstalled()
|
|
}
|
|
|
|
return Fail2banStatus{
|
|
Enabled: enabled,
|
|
Installed: installed,
|
|
Usable: enabled && installed,
|
|
Windows: runtime.GOOS == "windows",
|
|
}
|
|
}
|
|
|
|
func isFail2banEnabled() bool {
|
|
value, ok := os.LookupEnv("XUI_ENABLE_FAIL2BAN")
|
|
return !ok || value == "true"
|
|
}
|
|
|
|
func (s *ServerService) isFail2banInstalled() bool {
|
|
s.fail2banMu.Lock()
|
|
defer s.fail2banMu.Unlock()
|
|
|
|
if !s.fail2banCheckedAt.IsZero() && time.Since(s.fail2banCheckedAt) < fail2banInstalledCacheTTL {
|
|
return s.fail2banInstalled
|
|
}
|
|
|
|
err := exec.CommandContext(context.Background(), "fail2ban-client", "-h").Run()
|
|
s.fail2banInstalled = err == nil
|
|
s.fail2banCheckedAt = time.Now()
|
|
return s.fail2banInstalled
|
|
}
|
|
|
|
// RefreshStatus collects a new system snapshot and stores it as LastStatus.
|
|
// Returns the new snapshot (may be nil if collection failed). Called by the
|
|
// background ticker; the caller is responsible for any side effects
|
|
// (websocket broadcast). The realtime status served to the UI comes from
|
|
// this snapshot; the homepage overview ring is fed here too.
|
|
func (s *ServerService) RefreshStatus() *Status {
|
|
next := s.GetStatus(s.LastStatus())
|
|
if next == nil {
|
|
return nil
|
|
}
|
|
s.lastStatusMu.Lock()
|
|
s.lastStatus = next
|
|
s.lastStatusMu.Unlock()
|
|
s.AppendOverviewSample(time.Now(), next)
|
|
return next
|
|
}
|
|
|
|
// AppendOverviewSample writes one tick of the homepage overview metrics —
|
|
// CPU, memory/swap/disk percents, network throughput (bytes/s) and TCP/UDP
|
|
// connection counts — into the memory-only overview ring. Called by
|
|
// RefreshStatus on the @2s status cadence, so all series stay aligned.
|
|
// Only the overview keys are kept; this is not the removed system-history
|
|
// sampler (no extra series, no persistence).
|
|
func (s *ServerService) AppendOverviewSample(t time.Time, status *Status) {
|
|
if status == nil {
|
|
return
|
|
}
|
|
overviewMetrics.append("cpu", t, status.Cpu)
|
|
if status.Mem.Total > 0 {
|
|
overviewMetrics.append("mem", t, float64(status.Mem.Current)*100.0/float64(status.Mem.Total))
|
|
}
|
|
if status.Swap.Total > 0 {
|
|
overviewMetrics.append("swap", t, float64(status.Swap.Current)*100.0/float64(status.Swap.Total))
|
|
} else {
|
|
overviewMetrics.append("swap", t, 0)
|
|
}
|
|
overviewMetrics.append("netUp", t, float64(status.NetIO.Up))
|
|
overviewMetrics.append("netDown", t, float64(status.NetIO.Down))
|
|
if status.Disk.Total > 0 {
|
|
overviewMetrics.append("diskUsage", t, float64(status.Disk.Current)*100.0/float64(status.Disk.Total))
|
|
}
|
|
overviewMetrics.append("tcpCount", t, float64(status.TcpCount))
|
|
overviewMetrics.append("udpCount", t, float64(status.UdpCount))
|
|
}
|
|
|
|
// AggregateOverviewMetric returns up to maxPoints averaged buckets of size
|
|
// bucketSeconds for one overview metric (see OverviewMetricKeys). Output
|
|
// points have keys {"t": unixSec, "v": value}. It backs the seed fetch of
|
|
// the homepage overview curves; the ring behind it is memory-only, so
|
|
// shortly after a panel restart it legitimately returns few or no points.
|
|
func (s *ServerService) AggregateOverviewMetric(metric string, bucketSeconds int, maxPoints int) []map[string]any {
|
|
return overviewMetrics.aggregate(metric, bucketSeconds, maxPoints)
|
|
}
|
|
|
|
func getPublicIP(url string) string {
|
|
client := &http.Client{
|
|
Timeout: 3 * time.Second,
|
|
}
|
|
|
|
req, reqErr := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
|
|
if reqErr != nil {
|
|
return "N/A"
|
|
}
|
|
resp, err := client.Do(req)
|
|
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
|
|
}
|
|
|
|
var publicIPv4Services = []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",
|
|
}
|
|
|
|
var publicIPv6Services = []string{
|
|
"https://api6.ipify.org",
|
|
"https://ipv6.icanhazip.com",
|
|
"https://v6.api.ipinfo.io/ip",
|
|
"https://ipv6.myexternalip.com/raw",
|
|
"https://6.ident.me",
|
|
}
|
|
|
|
// resolvePublicIPs caches the public IPv4/IPv6 addresses on first use. Guarded
|
|
// by s.mu because the bot's ServerService may call it from sendBackup while a
|
|
// status report runs concurrently.
|
|
func (s *ServerService) resolvePublicIPs() {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if s.cachedIPv4 == "" {
|
|
for _, ip4Service := range publicIPv4Services {
|
|
s.cachedIPv4 = getPublicIP(ip4Service)
|
|
if s.cachedIPv4 != "N/A" {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if s.cachedIPv6 == "" && !s.noIPv6 {
|
|
for _, ip6Service := range publicIPv6Services {
|
|
s.cachedIPv6 = getPublicIP(ip6Service)
|
|
if s.cachedIPv6 != "N/A" {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if s.cachedIPv6 == "N/A" {
|
|
s.noIPv6 = true
|
|
}
|
|
}
|
|
|
|
func (s *ServerService) GetStatus(lastStatus *Status) *Status {
|
|
now := time.Now()
|
|
status := &Status{
|
|
T: now,
|
|
}
|
|
|
|
// CPU stats
|
|
util, err := s.sampleCPUUtilization()
|
|
if err != nil {
|
|
logger.Warning("get cpu percent failed:", err)
|
|
} else {
|
|
status.Cpu = util
|
|
}
|
|
|
|
status.CpuCores, err = cpu.Counts(false)
|
|
if err != nil {
|
|
logger.Warning("get cpu cores count failed:", err)
|
|
}
|
|
|
|
status.LogicalPro = runtime.NumCPU()
|
|
|
|
if status.CpuSpeedMhz = s.cachedCpuSpeedMhz; s.cachedCpuSpeedMhz == 0 && time.Since(s.lastCpuInfoAttempt) > 5*time.Minute {
|
|
s.lastCpuInfoAttempt = time.Now()
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
cpuInfos, err := cpu.Info()
|
|
if err != nil {
|
|
logger.Warning("get cpu info failed:", err)
|
|
return
|
|
}
|
|
if len(cpuInfos) > 0 {
|
|
s.cachedCpuSpeedMhz = cpuInfos[0].Mhz
|
|
status.CpuSpeedMhz = s.cachedCpuSpeedMhz
|
|
} else {
|
|
logger.Warning("could not find cpu info")
|
|
}
|
|
}()
|
|
select {
|
|
case <-done:
|
|
case <-time.After(1500 * time.Millisecond):
|
|
logger.Warning("cpu info query timed out; will retry later")
|
|
}
|
|
} else if s.cachedCpuSpeedMhz != 0 {
|
|
status.CpuSpeedMhz = s.cachedCpuSpeedMhz
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
diskIOStats, err := disk.IOCounters()
|
|
if err != nil {
|
|
logger.Warning("get disk io counters failed:", err)
|
|
} else {
|
|
var totalRead, totalWrite uint64
|
|
for _, counter := range diskIOStats {
|
|
totalRead += counter.ReadBytes
|
|
totalWrite += counter.WriteBytes
|
|
}
|
|
status.DiskTraffic.Read = totalRead
|
|
status.DiskTraffic.Write = totalWrite
|
|
|
|
if lastStatus != nil {
|
|
duration := now.Sub(lastStatus.T)
|
|
seconds := float64(duration) / float64(time.Second)
|
|
if seconds > 0 && status.DiskTraffic.Read >= lastStatus.DiskTraffic.Read {
|
|
status.DiskIO.Read = uint64(float64(status.DiskTraffic.Read-lastStatus.DiskTraffic.Read) / seconds)
|
|
}
|
|
if seconds > 0 && status.DiskTraffic.Write >= lastStatus.DiskTraffic.Write {
|
|
status.DiskIO.Write = uint64(float64(status.DiskTraffic.Write-lastStatus.DiskTraffic.Write) / seconds)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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(true)
|
|
if err != nil {
|
|
logger.Warning("get io counters failed:", err)
|
|
} else {
|
|
var totalSent, totalRecv, totalPktSent, totalPktRecv uint64
|
|
for _, iface := range ioStats {
|
|
name := strings.ToLower(iface.Name)
|
|
if isVirtualInterface(name) {
|
|
continue
|
|
}
|
|
totalSent += iface.BytesSent
|
|
totalRecv += iface.BytesRecv
|
|
totalPktSent += iface.PacketsSent
|
|
totalPktRecv += iface.PacketsRecv
|
|
}
|
|
status.NetTraffic.Sent = totalSent
|
|
status.NetTraffic.Recv = totalRecv
|
|
status.NetTraffic.PktSent = totalPktSent
|
|
status.NetTraffic.PktRecv = totalPktRecv
|
|
|
|
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
|
|
if seconds > 0 && status.NetTraffic.PktSent >= lastStatus.NetTraffic.PktSent {
|
|
status.NetIO.PktUp = uint64(float64(status.NetTraffic.PktSent-lastStatus.NetTraffic.PktSent) / seconds)
|
|
}
|
|
if seconds > 0 && status.NetTraffic.PktRecv >= lastStatus.NetTraffic.PktRecv {
|
|
status.NetIO.PktDown = uint64(float64(status.NetTraffic.PktRecv-lastStatus.NetTraffic.PktRecv) / seconds)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
s.resolvePublicIPs()
|
|
status.PublicIP.IPv4 = s.cachedIPv4
|
|
status.PublicIP.IPv6 = s.cachedIPv6
|
|
|
|
// Xray status: the lite master runs no local core, so it always
|
|
// reports stopped with no version.
|
|
status.Xray.State = Stop
|
|
status.Xray.ErrorMsg = ""
|
|
status.Xray.Version = ""
|
|
|
|
var amneziawgCount int64
|
|
if err := database.GetDB().Model(model.Inbound{}).
|
|
Where("protocol = ? AND enable = ? AND node_id IS NULL", model.AmneziaWG, true).
|
|
Count(&amneziawgCount).Error; err != nil {
|
|
logger.Warning("count amneziawg inbounds failed:", err)
|
|
}
|
|
status.AmneziaWG.Configured = amneziawgCount > 0
|
|
status.AmneziaWG.Running = amneziawgnet.GetManager().HasRunning()
|
|
|
|
status.PanelVersion = config.GetPanelVersion()
|
|
if guid, err := s.settingService.GetPanelGuid(); err == nil {
|
|
status.PanelGuid = guid
|
|
}
|
|
|
|
// Application stats
|
|
if rss := sys.SelfRSS(); rss > 0 {
|
|
status.AppStats.Mem = rss
|
|
} else {
|
|
var rtm runtime.MemStats
|
|
runtime.ReadMemStats(&rtm)
|
|
status.AppStats.Mem = rtm.Sys
|
|
}
|
|
status.AppStats.Threads = uint32(runtime.NumGoroutine())
|
|
status.AppStats.Uptime = 0
|
|
|
|
return status
|
|
}
|
|
|
|
func (s *ServerService) sampleCPUUtilization() (float64, error) {
|
|
// Try native platform-specific CPU implementation first (Windows, Linux, macOS)
|
|
if pct, err := sys.CPUPercentRaw(); err == nil {
|
|
s.mu.Lock()
|
|
// First call to native method returns 0 (initializes baseline)
|
|
if !s.hasNativeCPUSample {
|
|
s.hasNativeCPUSample = true
|
|
s.mu.Unlock()
|
|
return 0, nil
|
|
}
|
|
// Smooth with EMA
|
|
const alpha = 0.3
|
|
if s.emaCPU == 0 {
|
|
s.emaCPU = pct
|
|
} else {
|
|
s.emaCPU = alpha*pct + (1-alpha)*s.emaCPU
|
|
}
|
|
val := s.emaCPU
|
|
s.mu.Unlock()
|
|
return val, nil
|
|
}
|
|
// If native call fails, fall back to gopsutil times
|
|
// Read aggregate CPU times (all CPUs combined)
|
|
times, err := cpu.Times(false)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if len(times) == 0 {
|
|
return 0, fmt.Errorf("no cpu times available")
|
|
}
|
|
|
|
cur := times[0]
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
// If this is the first sample, initialize and return current EMA (0 by default)
|
|
if !s.hasLastCPUSample {
|
|
s.lastCPUTimes = cur
|
|
s.hasLastCPUSample = true
|
|
return s.emaCPU, nil
|
|
}
|
|
|
|
// Compute busy and total deltas
|
|
// Note: Guest and GuestNice times are already included in User and Nice respectively,
|
|
// so we exclude them to avoid double-counting (Linux kernel accounting)
|
|
idleDelta := cur.Idle - s.lastCPUTimes.Idle
|
|
busyDelta := (cur.User - s.lastCPUTimes.User) +
|
|
(cur.System - s.lastCPUTimes.System) +
|
|
(cur.Nice - s.lastCPUTimes.Nice) +
|
|
(cur.Iowait - s.lastCPUTimes.Iowait) +
|
|
(cur.Irq - s.lastCPUTimes.Irq) +
|
|
(cur.Softirq - s.lastCPUTimes.Softirq) +
|
|
(cur.Steal - s.lastCPUTimes.Steal)
|
|
|
|
totalDelta := busyDelta + idleDelta
|
|
|
|
// Update last sample for next time
|
|
s.lastCPUTimes = cur
|
|
|
|
// Guard against division by zero or negative deltas (e.g., counter resets)
|
|
if totalDelta <= 0 {
|
|
return s.emaCPU, nil
|
|
}
|
|
|
|
raw := 100.0 * (busyDelta / totalDelta)
|
|
if raw < 0 {
|
|
raw = 0
|
|
}
|
|
if raw > 100 {
|
|
raw = 100
|
|
}
|
|
|
|
// Exponential moving average to smooth spikes
|
|
const alpha = 0.3 // smoothing factor (0<alpha<=1). Higher = more responsive, lower = smoother
|
|
if s.emaCPU == 0 {
|
|
// Initialize EMA with the first real reading to avoid long warm-up from zero
|
|
s.emaCPU = raw
|
|
} else {
|
|
s.emaCPU = alpha*raw + (1-alpha)*s.emaCPU
|
|
}
|
|
|
|
return s.emaCPU, nil
|
|
}
|
|
|
|
// StopXrayService is a no-op on the lite master: there is no local core to
|
|
// stop. It stays so the DB import/restore flows keep their shape.
|
|
func (s *ServerService) StopXrayService() error {
|
|
return nil
|
|
}
|
|
|
|
// RestartXrayService is a no-op on the lite master: there is no local core
|
|
// to restart. Agent nodes pick up changes on their next config pull.
|
|
func (s *ServerService) RestartXrayService() error {
|
|
return nil
|
|
}
|
|
|
|
// isVirtualInterface returns true for loopback and virtual/tunnel interfaces
|
|
// that should be excluded from network traffic statistics.
|
|
func isVirtualInterface(name string) bool {
|
|
// Exact matches
|
|
if name == "lo" || name == "lo0" {
|
|
return true
|
|
}
|
|
// Prefix matches for virtual/tunnel interfaces
|
|
virtualPrefixes := []string{
|
|
"loopback",
|
|
"docker",
|
|
"br-",
|
|
"veth",
|
|
"virbr",
|
|
"tun",
|
|
"tap",
|
|
"wg",
|
|
"tailscale",
|
|
"zt",
|
|
}
|
|
for _, prefix := range virtualPrefixes {
|
|
if strings.HasPrefix(name, prefix) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// GetConfigJson returns the stored xray template as JSON. The lite master
|
|
// builds no local config (there is no local core to run one), so this is
|
|
// the template itself — the same document the settings page edits.
|
|
func (s *ServerService) GetConfigJson() (any, error) {
|
|
template, err := s.settingService.GetXrayConfigTemplate()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var jsonData any
|
|
if err := json.Unmarshal([]byte(template), &jsonData); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return jsonData, nil
|
|
}
|
|
|
|
func (s *ServerService) GetDb() ([]byte, error) {
|
|
if database.IsPostgres() {
|
|
return s.exportPostgresDB()
|
|
}
|
|
backupPath, cleanup, err := s.backupSQLite()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer cleanup()
|
|
return os.ReadFile(backupPath)
|
|
}
|
|
|
|
func (s *ServerService) backupSQLite() (string, func(), error) {
|
|
backupDir, err := os.MkdirTemp(filepath.Dir(config.GetDBPath()), ".x-ui-backup-")
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
cleanup := func() { _ = os.RemoveAll(backupDir) }
|
|
backupPath := filepath.Join(backupDir, "backup.db")
|
|
if err := database.BackupSQLite(backupPath); err != nil {
|
|
cleanup()
|
|
return "", nil, err
|
|
}
|
|
return backupPath, cleanup, nil
|
|
}
|
|
|
|
// BackupFilename returns the filename for a database backup, named after the
|
|
// panel's address so a downloaded or Telegram-sent backup identifies the server
|
|
// it came from, followed by the current date and time (_YYYY-MM-DD_HHMMSS) so
|
|
// files accumulated in Telegram chat history group by server then sort
|
|
// chronologically and same-day backups stay distinct. requestHost is the
|
|
// browser's address: the getDb handler passes c.Request.Host so a panel download
|
|
// is named after whatever address the user reached the panel with, no Listen
|
|
// Domain needed. The Telegram bot has no request and passes "", falling back to
|
|
// the configured Listen Domain (webDomain) and then the public IP. The extension
|
|
// is .dump on PostgreSQL and .db on SQLite; the base falls back to "x-ui" when
|
|
// no address is known.
|
|
func (s *ServerService) BackupFilename(requestHost string) string {
|
|
ext := ".db"
|
|
if database.IsPostgres() {
|
|
ext = ".dump"
|
|
}
|
|
return s.backupHost(requestHost) + backupDateSuffix(time.Now()) + ext
|
|
}
|
|
|
|
// backupDateSuffix returns the _YYYY-MM-DD_HHMMSS chronological suffix appended
|
|
// after the host in backup filenames. Uses server-local time for consistency
|
|
// with the timestamp printed in the Telegram backup message body.
|
|
func backupDateSuffix(now time.Time) string {
|
|
return "_" + now.Format("2006-01-02_150405")
|
|
}
|
|
|
|
// backupHost picks the address used to name backup files: the browser's request
|
|
// host (port stripped) when available, otherwise the configured Listen Domain
|
|
// (webDomain) and then the resolved public IP (IPv4 before IPv6), reduced to safe
|
|
// filename characters. The public IP is resolved directly rather than read from
|
|
// LastStatus so callers whose ServerService never runs the status ticker —
|
|
// notably the Telegram bot — still get a real address instead of the "x-ui"
|
|
// fallback.
|
|
func (s *ServerService) backupHost(requestHost string) string {
|
|
host := extractHostname(strings.TrimSpace(requestHost))
|
|
if host == "" {
|
|
if domain, err := s.settingService.GetWebDomain(); err == nil {
|
|
host = strings.TrimSpace(domain)
|
|
}
|
|
}
|
|
if host == "" {
|
|
s.resolvePublicIPs()
|
|
if ip := s.cachedIPv4; ip != "" && ip != "N/A" {
|
|
host = ip
|
|
} else if ip := s.cachedIPv6; ip != "" && ip != "N/A" {
|
|
host = ip
|
|
}
|
|
}
|
|
return sanitizeBackupHost(host)
|
|
}
|
|
|
|
// sanitizeBackupHost reduces a host to characters safe in a download filename
|
|
// (the getDb handler enforces ^[a-zA-Z0-9_\-.]+$). IPv6 brackets are stripped
|
|
// and any other character — such as the colons in an IPv6 address — becomes a
|
|
// hyphen. Returns "x-ui" when nothing usable remains.
|
|
func sanitizeBackupHost(host string) string {
|
|
host = strings.Trim(host, "[]")
|
|
var b strings.Builder
|
|
for _, r := range host {
|
|
switch {
|
|
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '.', r == '-', r == '_':
|
|
b.WriteRune(r)
|
|
default:
|
|
b.WriteRune('-')
|
|
}
|
|
}
|
|
out := strings.Trim(b.String(), ".-")
|
|
if out == "" {
|
|
return "x-ui"
|
|
}
|
|
return out
|
|
}
|
|
|
|
// GetMigration produces a cross-engine migration file plus its filename: on a
|
|
// SQLite panel it returns a portable .dump (SQL text), and on a PostgreSQL panel
|
|
// it returns a .db SQLite database built from the live data. Either output can
|
|
// then seed a panel running on the other backend.
|
|
func (s *ServerService) GetMigration() ([]byte, string, error) {
|
|
if database.IsPostgres() {
|
|
tmp, err := os.CreateTemp("", "x-ui-migration-*.db")
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
tmpPath := tmp.Name()
|
|
tmp.Close()
|
|
defer os.Remove(tmpPath)
|
|
|
|
if err := database.ExportPostgresToSQLite(config.GetDBDSN(), tmpPath); err != nil {
|
|
return nil, "", err
|
|
}
|
|
data, err := os.ReadFile(tmpPath)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
return data, "x-ui.db", nil
|
|
}
|
|
|
|
backupPath, cleanup, err := s.backupSQLite()
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
defer cleanup()
|
|
data, err := database.DumpSQLiteToBytes(backupPath)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
return data, "x-ui.dump", nil
|
|
}
|
|
|
|
// hostBoundSettingKeys are the settings that describe *this* machine rather
|
|
// than the configuration being carried: where the panel and the subscription
|
|
// service listen, the certificates they present, and the identity this panel
|
|
// uses towards its nodes. An import that overwrites them leaves the
|
|
// destination unreachable on its own address, or impersonating the source.
|
|
var hostBoundSettingKeys = []string{
|
|
"webListen", "webDomain", "webPort", "webCertFile", "webKeyFile", "webBasePath",
|
|
"subListen", "subDomain", "subPort", "subCertFile", "subKeyFile", "subURI", "subJsonURI",
|
|
"secret", "panelGuid",
|
|
"nodeMtlsCaCertPem", "nodeMtlsCaKeyPem", "nodeMtlsClientCertPem",
|
|
"nodeMtlsClientKeyPem", "nodeMtlsClientCertSha256", "nodeMtlsClientCAPem",
|
|
}
|
|
|
|
// hostBoundSnapshot records this machine's values, and just as importantly
|
|
// which keys it had no row for: an absent row means the built-in default is in
|
|
// force, and leaving the imported row in place would silently adopt the source
|
|
// machine's certificate path or listen address.
|
|
type hostBoundSnapshot struct {
|
|
values map[string]string
|
|
present map[string]struct{}
|
|
taken bool
|
|
}
|
|
|
|
func captureHostBoundSettings() hostBoundSnapshot {
|
|
db := database.GetDB()
|
|
if db == nil {
|
|
return hostBoundSnapshot{}
|
|
}
|
|
var rows []model.Setting
|
|
if err := db.Model(&model.Setting{}).Where("key IN ?", hostBoundSettingKeys).Find(&rows).Error; err != nil {
|
|
logger.Warningf("Import: could not read this machine's settings, they will come from the uploaded file: %v", err)
|
|
return hostBoundSnapshot{}
|
|
}
|
|
snap := hostBoundSnapshot{
|
|
values: make(map[string]string, len(rows)),
|
|
present: make(map[string]struct{}, len(rows)),
|
|
taken: true,
|
|
}
|
|
for _, row := range rows {
|
|
snap.values[row.Key] = row.Value
|
|
snap.present[row.Key] = struct{}{}
|
|
}
|
|
return snap
|
|
}
|
|
|
|
func restoreHostBoundSettings(snap hostBoundSnapshot) {
|
|
if !snap.taken {
|
|
return
|
|
}
|
|
db := database.GetDB()
|
|
if db == nil {
|
|
return
|
|
}
|
|
settingSvc := &SettingService{}
|
|
for _, key := range hostBoundSettingKeys {
|
|
if _, had := snap.present[key]; !had {
|
|
// Absent because it is minted on demand, not because a default applied:
|
|
// the imported copy is the only one that exists, so keep it (#6227).
|
|
if lazilyMintedSettingKeys[key] {
|
|
continue
|
|
}
|
|
if err := db.Where("key = ?", key).Delete(&model.Setting{}).Error; err != nil {
|
|
logger.Warningf("Import: could not drop imported setting %q: %v", key, err)
|
|
}
|
|
continue
|
|
}
|
|
// saveSetting rather than Assign(struct): GORM drops zero-valued fields from
|
|
// the assignment map, so an empty local value never overwrote the import.
|
|
if err := settingSvc.saveSetting(key, snap.values[key]); err != nil {
|
|
logger.Warningf("Import: could not restore setting %q for this machine: %v", key, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Minted on demand, so a fresh install has no row: dropping the imported copy
|
|
// would destroy the only one that exists, CA private key included.
|
|
var lazilyMintedSettingKeys = map[string]bool{
|
|
"nodeMtlsCaCertPem": true,
|
|
"nodeMtlsCaKeyPem": true,
|
|
"nodeMtlsClientCertPem": true,
|
|
"nodeMtlsClientKeyPem": true,
|
|
"nodeMtlsClientCAPem": true,
|
|
}
|
|
|
|
func (s *ServerService) ImportDB(file multipart.File, keepHostSettings bool) error {
|
|
if database.IsPostgres() {
|
|
return s.importPostgresDB(file, keepHostSettings)
|
|
}
|
|
kind, err := sniffUploadKind(file)
|
|
if err != nil {
|
|
return common.NewErrorf("Error reading uploaded file: %v", err)
|
|
}
|
|
switch kind {
|
|
case importKindSQLiteDB, importKindSQLiteDump:
|
|
case importKindPgDump:
|
|
return common.NewError("This file is a PostgreSQL backup; it can only be restored on a panel running PostgreSQL")
|
|
default:
|
|
return common.NewError("Invalid file: expected a SQLite database (.db) from Back Up or a SQLite migration dump (.dump)")
|
|
}
|
|
|
|
tempPath := fmt.Sprintf("%s.temp", config.GetDBPath())
|
|
|
|
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)
|
|
}
|
|
}
|
|
defer func() {
|
|
if _, err := os.Stat(tempPath); err == nil {
|
|
if rerr := os.Remove(tempPath); rerr != nil {
|
|
logger.Warningf("Warning: failed to remove temp file: %v", rerr)
|
|
}
|
|
}
|
|
}()
|
|
|
|
if err := stageSQLiteUpload(file, kind, tempPath); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err = database.ValidateSQLiteDB(tempPath); err != nil {
|
|
return common.NewErrorf("Invalid or corrupt db file: %v", err)
|
|
}
|
|
if err = database.PrepareSQLiteForMigration(tempPath); err != nil {
|
|
return common.NewErrorf("This file cannot be imported: %v", err)
|
|
}
|
|
|
|
xrayStopped := true
|
|
defer func() {
|
|
if xrayStopped {
|
|
if errR := s.RestartXrayService(); errR != nil {
|
|
logger.Warningf("Failed to restart Xray after DB import error: %v", errR)
|
|
}
|
|
}
|
|
}()
|
|
if errStop := s.StopXrayService(); errStop != nil {
|
|
logger.Warningf("Failed to stop Xray before DB import: %v", errStop)
|
|
}
|
|
|
|
var keptSettings hostBoundSnapshot
|
|
if keepHostSettings {
|
|
keptSettings = captureHostBoundSettings()
|
|
}
|
|
|
|
if errClose := database.CloseDB(); errClose != nil {
|
|
logger.Warningf("Failed to close existing DB before replacement: %v", errClose)
|
|
}
|
|
|
|
// Registered after the xray-restart defer so it runs first (LIFO): every
|
|
// error return below leaves a database file at the configured path, and the
|
|
// restart needs an open pool to build the xray config from it.
|
|
dbReopened := false
|
|
defer func() {
|
|
if dbReopened {
|
|
return
|
|
}
|
|
if errReopen := database.InitDB(config.GetDBPath()); errReopen != nil {
|
|
logger.Warningf("Failed to reopen the database after import error: %v", errReopen)
|
|
}
|
|
}()
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// Open & migrate new DB
|
|
if err = database.InitDB(config.GetDBPath()); err != nil {
|
|
// A failed InitDB still holds the imported file open; close before the
|
|
// rename or Windows refuses to replace it.
|
|
if errClose := database.CloseDB(); errClose != nil {
|
|
logger.Warningf("Failed to close the imported DB before restoring fallback: %v", errClose)
|
|
}
|
|
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)
|
|
}
|
|
dbReopened = true
|
|
|
|
restoreHostBoundSettings(keptSettings)
|
|
|
|
s.inboundService.MigrateDB()
|
|
|
|
xrayStopped = false
|
|
if err = s.RestartXrayService(); err != nil {
|
|
return common.NewErrorf("Imported DB but failed to start Xray: %v; the previous database was kept at %s", err, fallbackPath)
|
|
}
|
|
|
|
if _, err := os.Stat(fallbackPath); err == nil {
|
|
if rerr := os.Remove(fallbackPath); rerr != nil {
|
|
logger.Warningf("Warning: failed to remove fallback file: %v", rerr)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// pgConnEnv turns the configured PostgreSQL DSN into the PG* environment used by
|
|
// pg_dump/pg_restore, keeping the password out of the process argument list.
|
|
func pgConnEnv(dsn string) (env []string, dbname string, err error) {
|
|
u, err := url.Parse(strings.TrimSpace(dsn))
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
if u.Scheme != "postgres" && u.Scheme != "postgresql" {
|
|
return nil, "", common.NewErrorf("unsupported DSN scheme %q", u.Scheme)
|
|
}
|
|
dbname = strings.TrimPrefix(u.Path, "/")
|
|
if dbname == "" {
|
|
return nil, "", common.NewError("PostgreSQL DSN is missing a database name")
|
|
}
|
|
host := u.Hostname()
|
|
if host == "" {
|
|
host = "127.0.0.1"
|
|
}
|
|
port := u.Port()
|
|
if port == "" {
|
|
port = "5432"
|
|
}
|
|
env = append(os.Environ(), "PGHOST="+host, "PGPORT="+port, "PGDATABASE="+dbname)
|
|
if user := u.User.Username(); user != "" {
|
|
env = append(env, "PGUSER="+user)
|
|
}
|
|
if pass, ok := u.User.Password(); ok {
|
|
env = append(env, "PGPASSWORD="+pass)
|
|
}
|
|
if sslmode := u.Query().Get("sslmode"); sslmode != "" {
|
|
env = append(env, "PGSSLMODE="+sslmode)
|
|
}
|
|
return env, dbname, nil
|
|
}
|
|
|
|
func (s *ServerService) exportPostgresDB() ([]byte, error) {
|
|
bin, err := exec.LookPath("pg_dump")
|
|
if err != nil {
|
|
return nil, common.NewError("pg_dump not found on the server; install the postgresql-client package to back up a PostgreSQL database")
|
|
}
|
|
env, dbname, err := pgConnEnv(config.GetDBDSN())
|
|
if err != nil {
|
|
return nil, common.NewErrorf("invalid PostgreSQL DSN: %v", err)
|
|
}
|
|
cmd := exec.CommandContext(context.Background(), bin, "--format=custom", "--no-owner", "--no-privileges", "--dbname", dbname)
|
|
cmd.Env = env
|
|
var out, stderr bytes.Buffer
|
|
cmd.Stdout = &out
|
|
cmd.Stderr = &stderr
|
|
if err := cmd.Run(); err != nil {
|
|
return nil, common.NewErrorf("pg_dump failed: %v: %s", err, strings.TrimSpace(stderr.String()))
|
|
}
|
|
return out.Bytes(), nil
|
|
}
|
|
|
|
var (
|
|
pgUnsupportedDumpVersionPattern = regexp.MustCompile(`unsupported version \((\d+\.\d+)\) in file header`)
|
|
pgToolVersionPattern = regexp.MustCompile(`\d+(?:\.\d+)+`)
|
|
)
|
|
|
|
var pgArchiveVersionIntroducedIn = map[string]int{
|
|
"1.15": 16,
|
|
"1.16": 17,
|
|
}
|
|
|
|
// checkPgRestoreCanRead probes the dump with pg_restore --list (reads only the
|
|
// TOC, no database connection) so an unreadable file fails before Xray is stopped.
|
|
func checkPgRestoreCanRead(bin, dumpPath string) error {
|
|
cmd := exec.CommandContext(context.Background(), bin, "--list", dumpPath)
|
|
cmd.Stdout = io.Discard
|
|
var stderr bytes.Buffer
|
|
cmd.Stderr = &stderr
|
|
if cmd.Run() == nil {
|
|
return nil
|
|
}
|
|
return pgRestoreReadFailureError(strings.TrimSpace(stderr.String()), pgRestoreVersion(bin))
|
|
}
|
|
|
|
func pgRestoreReadFailureError(probeOutput, localVersion string) error {
|
|
m := pgUnsupportedDumpVersionPattern.FindStringSubmatch(probeOutput)
|
|
if m == nil {
|
|
return common.NewErrorf("pg_restore cannot read this dump file: %s", probeOutput)
|
|
}
|
|
if localVersion == "" {
|
|
localVersion = "unknown"
|
|
}
|
|
if major, known := pgArchiveVersionIntroducedIn[m[1]]; known {
|
|
return common.NewErrorf("This backup was created by pg_dump from PostgreSQL %d or newer, but the server's pg_restore is version %s and cannot read it; run 'x-ui pgclient %d' on the server (or upgrade the postgresql-client package to version %d or newer), then retry the import", major, localVersion, major, major)
|
|
}
|
|
return common.NewErrorf("This backup was created by a newer pg_dump than the server's pg_restore (version %s) can read; upgrade the postgresql-client package and retry the import", localVersion)
|
|
}
|
|
|
|
func pgRestoreVersion(bin string) string {
|
|
out, err := exec.CommandContext(context.Background(), bin, "--version").Output()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return parsePgToolVersion(string(out))
|
|
}
|
|
|
|
func parsePgToolVersion(versionOutput string) string {
|
|
return pgToolVersionPattern.FindString(versionOutput)
|
|
}
|
|
|
|
const (
|
|
importKindUnknown = iota
|
|
importKindPgDump
|
|
importKindSQLiteDB
|
|
importKindSQLiteDump
|
|
)
|
|
|
|
// sniffImportKind classifies an uploaded restore file by its leading bytes:
|
|
// a pg_dump custom archive, a raw SQLite database, or a SQLite SQL text dump.
|
|
func sniffImportKind(header []byte) int {
|
|
if bytes.HasPrefix(header, []byte("PGDMP")) {
|
|
return importKindPgDump
|
|
}
|
|
if bytes.HasPrefix(header, []byte("SQLite format 3\x00")) {
|
|
return importKindSQLiteDB
|
|
}
|
|
text := bytes.TrimLeft(bytes.TrimPrefix(header, []byte("\xef\xbb\xbf")), " \t\r\n")
|
|
if bytes.HasPrefix(text, []byte("PRAGMA")) || bytes.HasPrefix(text, []byte("BEGIN TRANSACTION")) {
|
|
return importKindSQLiteDump
|
|
}
|
|
return importKindUnknown
|
|
}
|
|
|
|
func sniffUploadKind(file multipart.File) (int, error) {
|
|
header := make([]byte, 64)
|
|
n, err := file.ReadAt(header, 0)
|
|
if err != nil && !errors.Is(err, io.EOF) {
|
|
return importKindUnknown, err
|
|
}
|
|
if _, err := file.Seek(0, 0); err != nil {
|
|
return importKindUnknown, err
|
|
}
|
|
return sniffImportKind(header[:n]), nil
|
|
}
|
|
|
|
func (s *ServerService) importPostgresDB(file multipart.File, keepHostSettings bool) error {
|
|
kind, err := sniffUploadKind(file)
|
|
if err != nil {
|
|
return common.NewErrorf("Error reading uploaded file: %v", err)
|
|
}
|
|
switch kind {
|
|
case importKindPgDump:
|
|
return s.restorePostgresDump(file, keepHostSettings)
|
|
case importKindSQLiteDB:
|
|
return s.migrateSQLiteIntoPostgres(file, false)
|
|
case importKindSQLiteDump:
|
|
return s.migrateSQLiteIntoPostgres(file, true)
|
|
default:
|
|
return common.NewError("Invalid file: expected a PostgreSQL custom-format dump (.dump) from this panel's Back Up, a SQLite database (.db), or a SQLite migration dump")
|
|
}
|
|
}
|
|
|
|
func (s *ServerService) restorePostgresDump(file multipart.File, keepHostSettings bool) error {
|
|
bin, err := exec.LookPath("pg_restore")
|
|
if err != nil {
|
|
return common.NewError("pg_restore not found on the server; install the postgresql-client package to restore a PostgreSQL database")
|
|
}
|
|
env, dbname, err := pgConnEnv(config.GetDBDSN())
|
|
if err != nil {
|
|
return common.NewErrorf("invalid PostgreSQL DSN: %v", err)
|
|
}
|
|
|
|
tempFile, err := os.CreateTemp("", "x-ui-pg-restore-*.dump")
|
|
if err != nil {
|
|
return common.NewErrorf("Error creating temporary dump file: %v", err)
|
|
}
|
|
tempPath := tempFile.Name()
|
|
defer os.Remove(tempPath)
|
|
if _, err := io.Copy(tempFile, file); err != nil {
|
|
tempFile.Close()
|
|
return common.NewErrorf("Error saving dump: %v", err)
|
|
}
|
|
if err := tempFile.Close(); err != nil {
|
|
return common.NewErrorf("Error closing temporary dump file: %v", err)
|
|
}
|
|
|
|
if err := checkPgRestoreCanRead(bin, tempPath); err != nil {
|
|
return err
|
|
}
|
|
|
|
xrayStopped := true
|
|
defer func() {
|
|
if xrayStopped {
|
|
if errR := s.RestartXrayService(); errR != nil {
|
|
logger.Warningf("Failed to restart Xray after DB restore error: %v", errR)
|
|
}
|
|
}
|
|
}()
|
|
if errStop := s.StopXrayService(); errStop != nil {
|
|
logger.Warningf("Failed to stop Xray before DB restore: %v", errStop)
|
|
}
|
|
|
|
var keptSettings hostBoundSnapshot
|
|
if keepHostSettings {
|
|
keptSettings = captureHostBoundSettings()
|
|
}
|
|
|
|
if errClose := database.CloseDB(); errClose != nil {
|
|
logger.Warningf("Failed to close existing DB before restore: %v", errClose)
|
|
}
|
|
|
|
cmd := exec.CommandContext(context.Background(), bin,
|
|
"--clean", "--if-exists", "--no-owner", "--no-privileges",
|
|
"--single-transaction", "--dbname", dbname, tempPath,
|
|
)
|
|
cmd.Env = env
|
|
var stderr bytes.Buffer
|
|
cmd.Stderr = &stderr
|
|
runErr := cmd.Run()
|
|
|
|
if errInit := database.InitDB(config.GetDBPath()); errInit != nil {
|
|
return common.NewErrorf("Restore finished but reopening the database failed: %v", errInit)
|
|
}
|
|
restoreHostBoundSettings(keptSettings)
|
|
|
|
s.inboundService.MigrateDB()
|
|
|
|
if runErr != nil {
|
|
return common.NewErrorf("pg_restore failed (database left unchanged): %v: %s", runErr, strings.TrimSpace(stderr.String()))
|
|
}
|
|
|
|
xrayStopped = false
|
|
if err := s.RestartXrayService(); err != nil {
|
|
return common.NewErrorf("Restored DB but failed to start Xray: %v", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *ServerService) migrateSQLiteIntoPostgres(file multipart.File, isSQLDump bool) error {
|
|
tempDir, err := os.MkdirTemp("", "x-ui-pg-migrate-*")
|
|
if err != nil {
|
|
return common.NewErrorf("Error creating temporary folder: %v", err)
|
|
}
|
|
defer os.RemoveAll(tempDir)
|
|
|
|
uploadPath := filepath.Join(tempDir, "upload.db")
|
|
if isSQLDump {
|
|
uploadPath = filepath.Join(tempDir, "upload.dump")
|
|
}
|
|
if err := saveUploadedFile(file, uploadPath); err != nil {
|
|
return common.NewErrorf("Error saving uploaded file: %v", err)
|
|
}
|
|
|
|
dbPath := uploadPath
|
|
if isSQLDump {
|
|
dbPath = filepath.Join(tempDir, "restored.db")
|
|
if err := database.RestoreSQLite(uploadPath, dbPath); err != nil {
|
|
return common.NewErrorf("Error rebuilding a SQLite database from the migration dump: %v", err)
|
|
}
|
|
}
|
|
if err := database.ValidateSQLiteDB(dbPath); err != nil {
|
|
return common.NewErrorf("Invalid or corrupt db file: %v", err)
|
|
}
|
|
if err := database.PrepareSQLiteForMigration(dbPath); err != nil {
|
|
return common.NewErrorf("This file cannot be imported: %v", err)
|
|
}
|
|
|
|
xrayStopped := true
|
|
defer func() {
|
|
if xrayStopped {
|
|
if errR := s.RestartXrayService(); errR != nil {
|
|
logger.Warningf("Failed to restart Xray after DB restore error: %v", errR)
|
|
}
|
|
}
|
|
}()
|
|
if errStop := s.StopXrayService(); errStop != nil {
|
|
logger.Warningf("Failed to stop Xray before DB restore: %v", errStop)
|
|
}
|
|
|
|
if errClose := database.CloseDB(); errClose != nil {
|
|
logger.Warningf("Failed to close existing DB before restore: %v", errClose)
|
|
}
|
|
|
|
migrateErr := database.MigrateData(dbPath, config.GetDBDSN())
|
|
|
|
if errInit := database.InitDB(config.GetDBPath()); errInit != nil {
|
|
return common.NewErrorf("Restore finished but reopening the database failed: %v", errInit)
|
|
}
|
|
s.inboundService.MigrateDB()
|
|
|
|
if migrateErr != nil {
|
|
return common.NewErrorf("Importing the SQLite data into PostgreSQL failed: %v; the import runs in a single transaction, so the database was left unchanged", migrateErr)
|
|
}
|
|
|
|
xrayStopped = false
|
|
if err := s.RestartXrayService(); err != nil {
|
|
return common.NewErrorf("Restored DB but failed to start Xray: %v", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func saveUploadedFile(file multipart.File, dstPath string) error {
|
|
dst, err := os.Create(dstPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := io.Copy(dst, file); err != nil {
|
|
dst.Close()
|
|
return err
|
|
}
|
|
return dst.Close()
|
|
}
|
|
|
|
func stageSQLiteUpload(file multipart.File, kind int, tempPath string) error {
|
|
if kind == importKindSQLiteDump {
|
|
dumpPath := tempPath + ".dump"
|
|
defer os.Remove(dumpPath)
|
|
if err := saveUploadedFile(file, dumpPath); err != nil {
|
|
return common.NewErrorf("Error saving migration dump: %v", err)
|
|
}
|
|
if err := database.RestoreSQLite(dumpPath, tempPath); err != nil {
|
|
return common.NewErrorf("Error rebuilding a SQLite database from the migration dump: %v", err)
|
|
}
|
|
return nil
|
|
}
|
|
if err := saveUploadedFile(file, tempPath); err != nil {
|
|
return common.NewErrorf("Error saving db: %v", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// parseXrayKeyPairOutput reads the two-line "Label: value" output that xray's
|
|
// key-generation subcommands (x25519, mldsa65, mlkem768) print and returns the
|
|
// two values. Short or label-less output yields an error instead of panicking
|
|
// on an out-of-range slice index, so a future xray version that changes the
|
|
// format degrades to a 500 with a message rather than a crash.
|
|
func parseXrayKeyPairOutput(output string) (string, string, error) {
|
|
lines := strings.Split(output, "\n")
|
|
if len(lines) < 2 {
|
|
return "", "", common.NewError("unexpected key generator output")
|
|
}
|
|
first := strings.Split(lines[0], ":")
|
|
second := strings.Split(lines[1], ":")
|
|
if len(first) < 2 || len(second) < 2 {
|
|
return "", "", common.NewError("unexpected key generator output")
|
|
}
|
|
return strings.TrimSpace(first[1]), strings.TrimSpace(second[1]), nil
|
|
}
|
|
|
|
// generateX25519KeyPair returns a fresh X25519 key pair in the exact format
|
|
// `xray x25519` prints (base64 RawURLEncoding of the raw 32-byte keys), but
|
|
// generated in-process: a control-plane-only panel has no xray binary to shell
|
|
// out to, and shelling out made Reality inbound creation fail there.
|
|
func generateX25519KeyPair() (privateKey string, publicKey string, err error) {
|
|
priv, err := ecdh.X25519().GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
return base64.RawURLEncoding.EncodeToString(priv.Bytes()),
|
|
base64.RawURLEncoding.EncodeToString(priv.PublicKey().Bytes()), nil
|
|
}
|
|
|
|
func (s *ServerService) GetNewX25519Cert() (any, error) {
|
|
privateKey, publicKey, err := generateX25519KeyPair()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
keyPair := map[string]any{
|
|
"privateKey": privateKey,
|
|
"publicKey": publicKey,
|
|
}
|
|
|
|
return keyPair, nil
|
|
}
|
|
|
|
func (s *ServerService) GetNewmldsa65() (any, error) {
|
|
// Run the command
|
|
cmd := exec.CommandContext(context.Background(), xray.GetBinaryPath(), "mldsa65")
|
|
var out bytes.Buffer
|
|
cmd.Stdout = &out
|
|
err := cmd.Run()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
seed, verify, err := parseXrayKeyPairOutput(out.String())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
keyPair := map[string]any{
|
|
"seed": seed,
|
|
"verify": verify,
|
|
}
|
|
|
|
return keyPair, nil
|
|
}
|
|
|
|
// GetCertHash parses a certificate (from a file path or inline PEM/DER content)
|
|
// and returns the hex-encoded SHA-256 over each certificate's raw DER — the
|
|
// value xray-core's pinnedPeerCertSha256 (pcs) expects. Lets the panel fill the
|
|
// pinned-cert field from the inbound's own certificate without the user
|
|
// computing the hash by hand.
|
|
func (s *ServerService) GetCertHash(certFile string, certContent string) ([]string, error) {
|
|
var certBytes []byte
|
|
if path := strings.TrimSpace(certFile); path != "" {
|
|
// Guard against path traversal: only hash certificate files the panel
|
|
// already references in its own configuration (an inbound's TLS
|
|
// certificateFile or the panel's own web cert). The path handed to
|
|
// os.ReadFile comes from that allow-list, never directly from the
|
|
// caller-supplied value.
|
|
known, ok := s.resolveKnownCertFile(path)
|
|
if !ok {
|
|
return nil, common.NewError("certificate file is not referenced by any inbound or panel setting")
|
|
}
|
|
b, err := os.ReadFile(known)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
certBytes = b
|
|
} else if strings.TrimSpace(certContent) != "" {
|
|
certBytes = []byte(certContent)
|
|
} else {
|
|
return nil, common.NewError("no certificate provided")
|
|
}
|
|
|
|
var certs []*x509.Certificate
|
|
if bytes.Contains(certBytes, []byte("BEGIN")) {
|
|
rest := certBytes
|
|
for {
|
|
block, remain := pem.Decode(rest)
|
|
if block == nil {
|
|
break
|
|
}
|
|
cert, err := x509.ParseCertificate(block.Bytes)
|
|
if err != nil {
|
|
return nil, common.NewError("unable to decode certificate: ", err)
|
|
}
|
|
certs = append(certs, cert)
|
|
rest = remain
|
|
}
|
|
} else {
|
|
parsed, err := x509.ParseCertificates(certBytes)
|
|
if err != nil {
|
|
return nil, common.NewError("unable to parse certificates: ", err)
|
|
}
|
|
certs = parsed
|
|
}
|
|
|
|
if len(certs) == 0 {
|
|
return nil, common.NewError("no certificates found")
|
|
}
|
|
|
|
hashes := make([]string, 0, len(certs))
|
|
for _, cert := range certs {
|
|
sum := sha256.Sum256(cert.Raw)
|
|
hashes = append(hashes, hex.EncodeToString(sum[:]))
|
|
}
|
|
return hashes, nil
|
|
}
|
|
|
|
// resolveKnownCertFile checks the caller-supplied certificate path against the
|
|
// set of certificate files the panel already references (inbound TLS configs
|
|
// plus the panel's own web cert) and, on a match, returns the path taken from
|
|
// that configuration — not the caller's value. This both confines reads to
|
|
// known certificates and breaks the user-input-to-filesystem taint flow.
|
|
func (s *ServerService) resolveKnownCertFile(certFile string) (string, bool) {
|
|
want := filepath.Clean(certFile)
|
|
for _, known := range s.knownCertFiles() {
|
|
if filepath.Clean(known) == want {
|
|
return known, true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// knownCertFiles collects every certificate file path the panel legitimately
|
|
// references: the certificateFile of each inbound's TLS settings and the
|
|
// panel's own web TLS certificate.
|
|
func (s *ServerService) knownCertFiles() []string {
|
|
var files []string
|
|
if cert, err := s.settingService.GetCertFile(); err == nil {
|
|
if cert = strings.TrimSpace(cert); cert != "" {
|
|
files = append(files, cert)
|
|
}
|
|
}
|
|
if inbounds, err := s.inboundService.GetAllInbounds(); err == nil {
|
|
for _, inbound := range inbounds {
|
|
files = collectCertFiles(inbound.StreamSettings, files)
|
|
}
|
|
}
|
|
return files
|
|
}
|
|
|
|
// collectCertFiles walks a stream-settings JSON document and appends the value
|
|
// of every "certificateFile" field it finds (TLS settings may nest them under
|
|
// several keys depending on the security type).
|
|
func collectCertFiles(streamSettings string, out []string) []string {
|
|
streamSettings = strings.TrimSpace(streamSettings)
|
|
if streamSettings == "" {
|
|
return out
|
|
}
|
|
var parsed any
|
|
if err := json.Unmarshal([]byte(streamSettings), &parsed); err != nil {
|
|
return out
|
|
}
|
|
return walkCertFiles(parsed, out)
|
|
}
|
|
|
|
func walkCertFiles(node any, out []string) []string {
|
|
switch v := node.(type) {
|
|
case map[string]any:
|
|
for key, val := range v {
|
|
if key == "certificateFile" {
|
|
if path, ok := val.(string); ok {
|
|
if path = strings.TrimSpace(path); path != "" {
|
|
out = append(out, path)
|
|
}
|
|
}
|
|
}
|
|
out = walkCertFiles(val, out)
|
|
}
|
|
case []any:
|
|
for _, item := range v {
|
|
out = walkCertFiles(item, out)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// GetRemoteCertHash opens a uTLS (Chrome fingerprint) handshake to a remote
|
|
// endpoint and returns the hex-encoded SHA-256 of its leaf certificate — the
|
|
// value to put in pinnedPeerCertSha256 (pcs) when pinning a server whose
|
|
// certificate file you don't hold (a CDN front, a REALITY dest, an external
|
|
// proxy). A native handshake replaces the old `xray tls ping` subprocess so the
|
|
// real dial/handshake failure (connection refused, timeout, …) surfaces
|
|
// verbatim. `server` may be host or host:port; the port defaults to 443.
|
|
func (s *ServerService) GetRemoteCertHash(server string) ([]string, error) {
|
|
server = strings.TrimSpace(server)
|
|
if server == "" {
|
|
return nil, common.NewError("no server provided")
|
|
}
|
|
|
|
host, port := server, "443"
|
|
if h, p, err := stdnet.SplitHostPort(server); err == nil {
|
|
host, port = h, p
|
|
}
|
|
|
|
dialer := stdnet.Dialer{Timeout: 10 * time.Second}
|
|
tcpConn, err := dialer.Dial("tcp", stdnet.JoinHostPort(host, port))
|
|
if err != nil {
|
|
return nil, common.NewErrorf("failed to dial %s: %s", stdnet.JoinHostPort(host, port), err)
|
|
}
|
|
defer tcpConn.Close()
|
|
_ = tcpConn.SetDeadline(time.Now().Add(15 * time.Second))
|
|
|
|
tlsConn := utls.UClient(tcpConn, &utls.Config{
|
|
ServerName: host,
|
|
InsecureSkipVerify: true,
|
|
NextProtos: []string{"h2", "http/1.1"},
|
|
}, utls.HelloChrome_Auto)
|
|
defer tlsConn.Close()
|
|
if err := tlsConn.Handshake(); err != nil {
|
|
return nil, common.NewErrorf("tls handshake with %s failed: %s", host, err)
|
|
}
|
|
|
|
certs := tlsConn.ConnectionState().PeerCertificates
|
|
if len(certs) == 0 {
|
|
return nil, common.NewError("no certificate returned by ", host)
|
|
}
|
|
// PeerCertificates[0] is always the leaf the connection verifies against —
|
|
// robust for IP-only self-signed certs that carry no DNS SANs.
|
|
sum := sha256.Sum256(certs[0].Raw)
|
|
return []string{hex.EncodeToString(sum[:])}, nil
|
|
}
|
|
|
|
func (s *ServerService) GetNewEchCert(sni string) (any, error) {
|
|
// Run the command
|
|
cmd := exec.CommandContext(context.Background(), 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]any{
|
|
"echServerKeys": serverKeys,
|
|
"echConfigList": configList,
|
|
}, nil
|
|
}
|
|
|
|
func (s *ServerService) GetNewVlessEnc() (any, error) {
|
|
cmd := exec.CommandContext(context.Background(), xray.GetBinaryPath(), "vlessenc")
|
|
var out bytes.Buffer
|
|
cmd.Stdout = &out
|
|
if err := cmd.Run(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
auths := parseVlessEncAuths(out.String())
|
|
auths = append(auths, deriveVlessEncModes(auths)...)
|
|
|
|
return map[string]any{
|
|
"auths": auths,
|
|
}, nil
|
|
}
|
|
|
|
func deriveVlessEncModes(auths []map[string]string) []map[string]string {
|
|
var extra []map[string]string
|
|
for _, a := range auths {
|
|
for _, mode := range []string{"xorpub", "random"} {
|
|
dec := strings.Replace(a["decryption"], ".native.", "."+mode+".", 1)
|
|
enc := strings.Replace(a["encryption"], ".native.", "."+mode+".", 1)
|
|
if dec == a["decryption"] && enc == a["encryption"] {
|
|
continue
|
|
}
|
|
extra = append(extra, map[string]string{
|
|
"id": a["id"] + "_" + mode,
|
|
"label": a["label"] + " (" + mode + ")",
|
|
"decryption": dec,
|
|
"encryption": enc,
|
|
})
|
|
}
|
|
}
|
|
return extra
|
|
}
|
|
|
|
func parseVlessEncAuths(output string) []map[string]string {
|
|
lines := strings.Split(output, "\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)
|
|
}
|
|
label := strings.TrimSpace(strings.TrimPrefix(line, "Authentication:"))
|
|
current = map[string]string{
|
|
"id": vlessEncAuthID(label),
|
|
"label": label,
|
|
}
|
|
} 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.TrimSpace(parts[1])
|
|
val = strings.TrimSuffix(val, ",")
|
|
val = strings.Trim(val, `" `)
|
|
current[key] = val
|
|
}
|
|
}
|
|
}
|
|
|
|
if current != nil {
|
|
auths = append(auths, current)
|
|
}
|
|
|
|
return auths
|
|
}
|
|
|
|
func vlessEncAuthID(label string) string {
|
|
normalized := strings.NewReplacer("-", "", "_", "", " ", "").Replace(strings.ToLower(label))
|
|
switch {
|
|
case strings.Contains(normalized, "mlkem768"):
|
|
return "mlkem768"
|
|
case strings.Contains(normalized, "x25519"):
|
|
return "x25519"
|
|
default:
|
|
return normalized
|
|
}
|
|
}
|
|
|
|
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.CommandContext(context.Background(), xray.GetBinaryPath(), "mlkem768")
|
|
var out bytes.Buffer
|
|
cmd.Stdout = &out
|
|
err := cmd.Run()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
seed, client, err := parseXrayKeyPairOutput(out.String())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
keyPair := map[string]any{
|
|
"seed": seed,
|
|
"client": client,
|
|
}
|
|
|
|
return keyPair, nil
|
|
}
|