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= '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 }