| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852 |
- package service
- import (
- "archive/zip"
- "bufio"
- "bytes"
- "cmp"
- "context"
- "crypto/sha256"
- "crypto/x509"
- "encoding/hex"
- "encoding/json"
- "encoding/pem"
- "errors"
- "fmt"
- "io"
- "maps"
- "math"
- "mime/multipart"
- stdnet "net"
- "net/http"
- "net/url"
- "os"
- "os/exec"
- "path"
- "path/filepath"
- "regexp"
- "runtime"
- "slices"
- "strconv"
- "strings"
- "sync"
- "time"
- "github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
- "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
- versionsCache *cachedXrayVersions
- fail2banMu sync.Mutex
- fail2banInstalled bool
- fail2banCheckedAt time.Time
- }
- type cachedXrayVersions struct {
- versions []string
- fetchedAt time.Time
- }
- // xrayVersionsCacheTTL bounds how often /getXrayVersion hits GitHub. The list
- // is purely informational (rendered in the "switch Xray version" picker) so a
- // quarter-hour staleness window is fine and saves the API budget.
- const xrayVersionsCacheTTL = 15 * time.Minute
- // 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 /server/history, /server/cpuHistory, /server/xrayMetricsHistory,
- // /server/xrayObservatoryHistory, and /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, stores it as LastStatus, and
- // appends it to the system-metrics time series. 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, xray metrics sample).
- 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.AppendStatusSample(time.Now(), next)
- return next
- }
- // GetXrayVersionsCached wraps GetXrayVersions with a TTL cache. On fetch
- // failure we serve the last successful list (if any) so the UI doesn't go
- // blank during a GitHub API hiccup; if there's no cache at all the underlying
- // error is surfaced.
- func (s *ServerService) GetXrayVersionsCached() ([]string, error) {
- s.versionsCacheMu.Lock()
- cache := s.versionsCache
- s.versionsCacheMu.Unlock()
- if cache != nil && time.Since(cache.fetchedAt) <= xrayVersionsCacheTTL {
- return cache.versions, nil
- }
- versions, err := s.GetXrayVersions()
- if err != nil {
- if cache != nil {
- logger.Warning("GetXrayVersionsCached: serving stale list:", err)
- return cache.versions, nil
- }
- return nil, err
- }
- s.versionsCacheMu.Lock()
- s.versionsCache = &cachedXrayVersions{versions: versions, fetchedAt: time.Now()}
- s.versionsCacheMu.Unlock()
- return versions, nil
- }
- // GetDefaultLogOutboundTags scans the default Xray config for freedom and
- // blackhole outbound tags so /getXrayLogs can colour-code log lines without
- // the controller re-doing the JSON walk. Falls back to the historical
- // "direct"/"blocked" defaults when the config can't be read.
- func (s *ServerService) GetDefaultLogOutboundTags() (freedoms, blackholes []string) {
- config, err := s.settingService.GetDefaultXrayConfig()
- if err == nil && config != nil {
- if cfgMap, ok := config.(map[string]any); ok {
- if outbounds, ok := cfgMap["outbounds"].([]any); ok {
- for _, outbound := range outbounds {
- obMap, ok := outbound.(map[string]any)
- if !ok {
- continue
- }
- tag, _ := obMap["tag"].(string)
- if tag == "" {
- continue
- }
- switch obMap["protocol"] {
- case "freedom":
- freedoms = append(freedoms, tag)
- case "blackhole":
- blackholes = append(blackholes, tag)
- }
- }
- }
- }
- }
- if len(freedoms) == 0 {
- freedoms = []string{"direct"}
- }
- if len(blackholes) == 0 {
- blackholes = []string{"blocked"}
- }
- return freedoms, blackholes
- }
- // AggregateCpuHistory returns up to maxPoints averaged buckets of size bucketSeconds.
- // Kept for back-compat with the original /panel/api/server/cpuHistory/:bucket route;
- // the response key is "cpu" (not "v") so legacy consumers parse unchanged.
- func (s *ServerService) AggregateCpuHistory(bucketSeconds int, maxPoints int) []map[string]any {
- out := systemMetrics.aggregate("cpu", bucketSeconds, maxPoints)
- for _, p := range out {
- p["cpu"] = p["v"]
- delete(p, "v")
- }
- return out
- }
- // AggregateSystemMetric returns up to maxPoints averaged buckets for any
- // known system metric (see SystemMetricKeys). Output points have keys
- // {"t": unixSec, "v": value}; the caller decides how to format the value.
- func (s *ServerService) AggregateSystemMetric(metric string, bucketSeconds int, maxPoints int) []map[string]any {
- return systemMetrics.aggregate(metric, bucketSeconds, maxPoints)
- }
- type LogEntry struct {
- DateTime time.Time `json:"DateTime" example:"2025-01-01T12:00:00Z"`
- FromAddress string `json:"FromAddress" example:"192.0.2.10:54321"`
- ToAddress string `json:"ToAddress" example:"example.com:443"`
- Inbound string `json:"Inbound" example:"inbound-443"`
- Outbound string `json:"Outbound" example:"direct"`
- Email string `json:"Email" example:"[email protected]"`
- Event int `json:"Event" example:"0"`
- }
- type NewUUIDResponse struct {
- UUID string `json:"uuid" example:"550e8400-e29b-41d4-a716-446655440000"`
- }
- type MLDSA65Response struct {
- Seed string `json:"seed" example:"mldsa65-seed"`
- Verify string `json:"verify" example:"mldsa65-verify"`
- }
- type MLKEM768Response struct {
- Seed string `json:"seed" example:"mlkem768-seed"`
- Client string `json:"client" example:"mlkem768-client"`
- }
- 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
- if s.xrayService.IsXrayRunning() {
- status.Xray.State = Running
- status.Xray.ErrorMsg = ""
- } else {
- err := s.xrayService.GetXrayErr()
- if err != nil {
- status.Xray.State = Error
- } else {
- status.Xray.State = Stop
- }
- status.Xray.ErrorMsg = s.xrayService.GetXrayResult()
- }
- status.Xray.Version = s.xrayService.GetXrayVersion()
- 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())
- if process := currentXrayProcess(); process != nil && process.IsRunning() {
- status.AppStats.Uptime = process.GetUptime()
- } else {
- status.AppStats.Uptime = 0
- }
- return status
- }
- // AppendCpuSample is preserved for callers that only have the CPU number.
- // New callers should prefer AppendStatusSample which writes the full set.
- func (s *ServerService) AppendCpuSample(t time.Time, v float64) {
- systemMetrics.append("cpu", t, v)
- }
- // AppendStatusSample writes one tick of every metric we keep — CPU, memory
- // percent, network throughput (bytes/s), online client count, and the three
- // load averages. Called by RefreshStatus on the same @2s cadence as
- // AppendCpuSample, so all series stay aligned.
- func (s *ServerService) AppendStatusSample(t time.Time, status *Status) {
- if status == nil {
- return
- }
- systemMetrics.append("cpu", t, status.Cpu)
- if status.Mem.Total > 0 {
- systemMetrics.append("mem", t, float64(status.Mem.Current)*100.0/float64(status.Mem.Total))
- }
- if status.Swap.Total > 0 {
- systemMetrics.append("swap", t, float64(status.Swap.Current)*100.0/float64(status.Swap.Total))
- } else {
- systemMetrics.append("swap", t, 0)
- }
- systemMetrics.append("netUp", t, float64(status.NetIO.Up))
- systemMetrics.append("netDown", t, float64(status.NetIO.Down))
- systemMetrics.append("diskRead", t, float64(status.DiskIO.Read))
- systemMetrics.append("diskWrite", t, float64(status.DiskIO.Write))
- if status.Disk.Total > 0 {
- systemMetrics.append("diskUsage", t, float64(status.Disk.Current)*100.0/float64(status.Disk.Total))
- }
- systemMetrics.append("pktUp", t, float64(status.NetIO.PktUp))
- systemMetrics.append("pktDown", t, float64(status.NetIO.PktDown))
- systemMetrics.append("tcpCount", t, float64(status.TcpCount))
- systemMetrics.append("udpCount", t, float64(status.UdpCount))
- online := 0
- if process := currentXrayProcess(); process != nil && process.IsRunning() {
- online = len(process.GetOnlineClients())
- }
- systemMetrics.append("online", t, float64(online))
- if len(status.Loads) >= 3 {
- systemMetrics.append("load1", t, status.Loads[0])
- systemMetrics.append("load5", t, status.Loads[1])
- systemMetrics.append("load15", t, status.Loads[2])
- }
- }
- 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
- }
- const (
- maxXrayArchiveBytes = 200 << 20
- maxXrayBinaryBytes = 200 << 20
- // maxXrayDigestBytes caps the .dgst checksum sidecar read; it is a few
- // hundred bytes in practice.
- maxXrayDigestBytes = 64 << 10
- )
- func (s *ServerService) GetXrayVersions() ([]string, error) {
- const (
- XrayURL = "https://api.github.com/repos/XTLS/Xray-core/releases"
- bufferSize = 8192
- )
- req, reqErr := http.NewRequestWithContext(context.Background(), http.MethodGet, XrayURL, nil)
- if reqErr != nil {
- return nil, reqErr
- }
- resp, err := s.settingService.NewProxiedHTTPClient(10 * time.Second).Do(req)
- if err != nil {
- return nil, err
- }
- defer resp.Body.Close()
- // Check HTTP status code - GitHub API returns object instead of array on error
- if resp.StatusCode != http.StatusOK {
- bodyBytes, _ := io.ReadAll(resp.Body)
- var errorResponse struct {
- Message string `json:"message"`
- }
- if json.Unmarshal(bodyBytes, &errorResponse) == nil && errorResponse.Message != "" {
- return nil, fmt.Errorf("GitHub API error: %s", errorResponse.Message)
- }
- return nil, fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, resp.Status)
- }
- buffer := bytes.NewBuffer(make([]byte, bufferSize))
- buffer.Reset()
- if _, err := buffer.ReadFrom(resp.Body); err != nil {
- return nil, err
- }
- var releases []Release
- if err := json.Unmarshal(buffer.Bytes(), &releases); err != nil {
- return nil, err
- }
- var versions []string
- for _, release := range releases {
- tagVersion := strings.TrimPrefix(release.TagName, "v")
- tagParts := strings.Split(tagVersion, ".")
- if len(tagParts) != 3 {
- continue
- }
- major, err1 := strconv.Atoi(tagParts[0])
- minor, err2 := strconv.Atoi(tagParts[1])
- patch, err3 := strconv.Atoi(tagParts[2])
- if err1 != nil || err2 != nil || err3 != nil {
- continue
- }
- if major > 26 || (major == 26 && minor > 6) || (major == 26 && minor == 6 && patch >= 27) {
- versions = append(versions, release.TagName)
- }
- }
- return versions, nil
- }
- func (s *ServerService) StopXrayService() error {
- err := s.xrayService.StopXray()
- if err != nil {
- logger.Error("stop xray failed:", err)
- return err
- }
- return nil
- }
- func (s *ServerService) RestartXrayService() error {
- err := s.xrayService.RestartXray(true)
- if err != nil {
- logger.Error("start xray failed:", err)
- return err
- }
- return nil
- }
- func (s *ServerService) downloadXRay(version string) (string, error) {
- osName := runtime.GOOS
- arch := runtime.GOARCH
- switch osName {
- case "darwin":
- osName = "macos"
- case "windows":
- osName = "windows"
- }
- switch arch {
- case "amd64":
- arch = "64"
- case "arm64":
- arch = "arm64-v8a"
- case "armv7":
- arch = "arm32-v7a"
- case "armv6":
- arch = "arm32-v6"
- case "armv5":
- arch = "arm32-v5"
- case "386":
- arch = "32"
- case "s390x":
- arch = "s390x"
- }
- fileName := fmt.Sprintf("Xray-%s-%s.zip", osName, arch)
- url := fmt.Sprintf("https://github.com/XTLS/Xray-core/releases/download/%s/%s", version, fileName)
- client := s.settingService.NewProxiedHTTPClient(60 * time.Second)
- req, reqErr := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
- if reqErr != nil {
- return "", reqErr
- }
- resp, err := client.Do(req)
- if err != nil {
- return "", err
- }
- defer resp.Body.Close()
- if resp.StatusCode != http.StatusOK {
- return "", fmt.Errorf("download xray: unexpected HTTP %d", resp.StatusCode)
- }
- if resp.ContentLength > maxXrayArchiveBytes {
- return "", fmt.Errorf("download xray: archive exceeds %d bytes", maxXrayArchiveBytes)
- }
- file, err := os.CreateTemp("", "xray-*.zip")
- if err != nil {
- return "", err
- }
- path := file.Name()
- ok := false
- defer func() {
- _ = file.Close()
- if !ok {
- _ = os.Remove(path)
- }
- }()
- n, err := io.Copy(file, io.LimitReader(resp.Body, maxXrayArchiveBytes+1))
- if err != nil {
- return "", err
- }
- if n > maxXrayArchiveBytes {
- return "", fmt.Errorf("download xray: archive exceeds %d bytes", maxXrayArchiveBytes)
- }
- // Verify the archive against the SHA2-256 published in the release's .dgst
- // sidecar before installing it. TLS protects the transport, not the artifact;
- // a corrupted or tampered asset must not be installed and run as xray.
- want, err := s.fetchXrayDigestSHA256(client, url+".dgst")
- if err != nil {
- return "", err
- }
- if _, err := file.Seek(0, io.SeekStart); err != nil {
- return "", err
- }
- hasher := sha256.New()
- if _, err := io.Copy(hasher, file); err != nil {
- return "", err
- }
- if got := hex.EncodeToString(hasher.Sum(nil)); !strings.EqualFold(got, want) {
- // User-facing warning: the archive's SHA-256 does not match the official
- // release checksum, so the download is corrupted or has been tampered
- // with. Abort the install so a bad binary is never run, and tell the user
- // to retry/re-download rather than proceed with a mismatched image.
- return "", fmt.Errorf("Xray update aborted: the downloaded archive does not match the official SHA-256 checksum, so the image is corrupted or differs from the official release. Please exit and re-download the official image, then try again (expected %s, got %s)", want, got)
- }
- ok = true
- return path, nil
- }
- // fetchXrayDigestSHA256 downloads the .dgst sidecar XTLS publishes next to each
- // release asset and returns the SHA2-256 hex digest it lists.
- func (s *ServerService) fetchXrayDigestSHA256(client *http.Client, dgstURL string) (string, error) {
- req, reqErr := http.NewRequestWithContext(context.Background(), http.MethodGet, dgstURL, nil)
- if reqErr != nil {
- return "", fmt.Errorf("download xray checksum: %w", reqErr)
- }
- resp, err := client.Do(req)
- if err != nil {
- return "", fmt.Errorf("download xray checksum: %w", err)
- }
- defer resp.Body.Close()
- if resp.StatusCode != http.StatusOK {
- return "", fmt.Errorf("download xray checksum: unexpected HTTP %d", resp.StatusCode)
- }
- raw, err := io.ReadAll(io.LimitReader(resp.Body, maxXrayDigestBytes))
- if err != nil {
- return "", fmt.Errorf("download xray checksum: %w", err)
- }
- return parseXrayDigestSHA256(raw)
- }
- // parseXrayDigestSHA256 extracts the lowercase SHA2-256 hex from an XTLS .dgst
- // file, whose lines are "ALGO= <hex>" (the relevant one being "SHA2-256= ...").
- func parseXrayDigestSHA256(dgst []byte) (string, error) {
- for line := range strings.SplitSeq(string(dgst), "\n") {
- rest, ok := strings.CutPrefix(strings.TrimSpace(line), "SHA2-256=")
- if !ok {
- continue
- }
- h := strings.ToLower(strings.TrimSpace(rest))
- if len(h) != 64 {
- return "", fmt.Errorf("xray checksum: malformed SHA2-256 entry in digest")
- }
- return h, nil
- }
- return "", fmt.Errorf("xray checksum: no SHA2-256 entry in digest")
- }
- func (s *ServerService) UpdateXray(version string) error {
- versions, err := s.GetXrayVersions()
- if err != nil {
- return err
- }
- if !slices.Contains(versions, version) {
- return fmt.Errorf("xray version %q is not in the fetched release list", version)
- }
- // 1. Stop xray before doing anything
- if err := s.StopXrayService(); err != nil {
- logger.Warning("failed to stop xray before update:", err)
- }
- // 2. Download the zip
- zipFileName, err := s.downloadXRay(version)
- if err != nil {
- return err
- }
- defer os.Remove(zipFileName)
- zipFile, err := os.Open(zipFileName)
- if err != nil {
- return err
- }
- defer zipFile.Close()
- stat, err := zipFile.Stat()
- if err != nil {
- return err
- }
- reader, err := zip.NewReader(zipFile, stat.Size())
- if err != nil {
- return err
- }
- // 3. Helper to extract files
- copyZipFile := func(zipName string, fileName string) error {
- zipFile, err := reader.Open(zipName)
- if err != nil {
- return err
- }
- defer zipFile.Close()
- if err := os.MkdirAll(filepath.Dir(fileName), 0o755); err != nil {
- return err
- }
- tmpFile, err := os.CreateTemp(filepath.Dir(fileName), ".xray-*")
- if err != nil {
- return err
- }
- tmpPath := tmpFile.Name()
- ok := false
- defer func() {
- _ = tmpFile.Close()
- if !ok {
- _ = os.Remove(tmpPath)
- }
- }()
- n, err := io.Copy(tmpFile, io.LimitReader(zipFile, maxXrayBinaryBytes+1))
- if err != nil {
- return err
- }
- if n > maxXrayBinaryBytes {
- return fmt.Errorf("xray binary exceeds %d bytes", maxXrayBinaryBytes)
- }
- if err := tmpFile.Chmod(0o755); err != nil {
- return err
- }
- if err := tmpFile.Close(); err != nil {
- return err
- }
- if runtime.GOOS == "windows" {
- _ = os.Remove(fileName)
- }
- if err := os.Rename(tmpPath, fileName); err != nil {
- return err
- }
- ok = true
- return nil
- }
- // 4. Extract correct binary
- if runtime.GOOS == "windows" {
- targetBinary := filepath.Join(config.GetBinFolderPath(), "xray-windows-amd64.exe")
- err = copyZipFile("xray.exe", targetBinary)
- } else {
- err = copyZipFile("xray", xray.GetBinaryPath())
- }
- if err != nil {
- return err
- }
- // 5. Restart xray
- if err := s.xrayService.RestartXray(true); err != nil {
- logger.Error("start xray failed:", err)
- return err
- }
- return nil
- }
- func (s *ServerService) GetLogs(count string, level string, syslog string) []string {
- c, _ := strconv.Atoi(count)
- var lines []string
- if syslog == "true" {
- // Check if running on Windows - journalctl is not available
- if runtime.GOOS == "windows" {
- return []string{"Syslog is not supported on Windows. Please use application logs instead by unchecking the 'Syslog' option."}
- }
- // Validate and sanitize count parameter
- countInt, err := strconv.Atoi(count)
- if err != nil || countInt < 1 || countInt > 10000 {
- return []string{"Invalid count parameter - must be a number between 1 and 10000"}
- }
- // Validate level parameter - only allow valid syslog levels
- validLevels := map[string]bool{
- "0": true, "emerg": true,
- "1": true, "alert": true,
- "2": true, "crit": true,
- "3": true, "err": true,
- "4": true, "warning": true,
- "5": true, "notice": true,
- "6": true, "info": true,
- "7": true, "debug": true,
- }
- if !validLevels[level] {
- return []string{"Invalid level parameter - must be a valid syslog level"}
- }
- // Use hardcoded command with validated parameters
- cmd := exec.CommandContext(context.Background(), "journalctl", "-u", "x-ui", "--no-pager", "-n", strconv.Itoa(countInt), "-p", level)
- var out bytes.Buffer
- cmd.Stdout = &out
- err = cmd.Run()
- if err != nil {
- return []string{"Failed to run journalctl command! Make sure systemd is available and x-ui service is registered."}
- }
- lines = strings.Split(out.String(), "\n")
- } else {
- lines = logger.GetLogs(c, level)
- }
- return lines
- }
- // parseAccessLogFields extracts the structured fields from one Xray access-log
- // line. Lines are attacker-influenced (a client's requested destination lands in
- // the log verbatim) and may be truncated, so every positional lookup is length
- // guarded: a malformed line yields a partial entry rather than panicking.
- func parseAccessLogFields(line string) LogEntry {
- var entry LogEntry
- parts := strings.Fields(line)
- for i, part := range parts {
- if i == 0 && len(parts) > 1 {
- dateTime, err := time.ParseInLocation("2006/01/02 15:04:05.999999", parts[0]+" "+parts[1], time.Local)
- if err != nil {
- continue
- }
- entry.DateTime = dateTime.UTC()
- }
- if part == "from" && i+1 < len(parts) {
- entry.FromAddress = strings.TrimLeft(parts[i+1], "/")
- } else if part == "accepted" && i+1 < len(parts) {
- entry.ToAddress = strings.TrimLeft(parts[i+1], "/")
- } else if strings.HasPrefix(part, "[") {
- entry.Inbound = part[1:]
- } else if strings.HasSuffix(part, "]") {
- entry.Outbound = part[:len(part)-1]
- } else if part == "email:" && i+1 < len(parts) {
- entry.Email = parts[i+1]
- }
- }
- return entry
- }
- // PeerActivity is one peer's live embedded-Device-reported state, the
- // counterpart of an Xray access-log entry: a tunnel logs no requests, only
- // handshakes and bytes.
- type PeerActivity struct {
- Interface string `json:"interface" example:"awg1"`
- Tag string `json:"tag" example:"inbound-51820"`
- InboundId int `json:"inboundId" example:"1"`
- Email string `json:"email" example:"[email protected]"`
- Endpoint string `json:"endpoint" example:"203.0.113.9:51820"`
- AllowedIPs string `json:"allowedIPs" example:"10.8.1.2/32"`
- // Handshake is unix milliseconds, 0 when the peer has never connected.
- Handshake int64 `json:"handshake" example:"1735732800000"`
- Up int64 `json:"up" example:"1048576"`
- Down int64 `json:"down" example:"4194304"`
- Online bool `json:"online" example:"true"`
- }
- // amneziawgOnlineWindow mirrors the standard WireGuard convention (and this
- // fork's own prior kernel-module behavior): a handshake this recent counts
- // as online.
- const amneziawgOnlineWindow = 180 * time.Second
- // AmneziaWGLogs is what the overview's AmneziaWG log view renders: the live
- // per-peer activity of every running embedded interface, plus the panel's
- // own recent AmneziaWG lifecycle log lines that explain a peer being absent
- // from Peers at all.
- type AmneziaWGLogs struct {
- Peers []PeerActivity `json:"peers"`
- Events []string `json:"events" example:"[\"2025/01/01 12:00:00 amneziawg: started interface awg1 for inbound 1\"]"`
- Running bool `json:"running" example:"true"`
- }
- // amneziawgEventMarker selects the panel's own AmneziaWG log lines: every
- // logger call in internal/amneziawg, internal/amneziawgnet and their jobs
- // prefixes its message with it.
- const amneziawgEventMarker = "amneziawg"
- // amneziawgLogActivity gathers live PeerActivity rows across every enabled,
- // non-node-hosted AmneziaWG inbound, newest handshake first. An inbound
- // amneziawgnet has no running Device for yet (not reconciled, disabled,
- // errored) contributes no rows -- not reported as an error, since the
- // caller (GetAmneziaWGLogs) already has a device-agnostic Running flag from
- // amneziawgnet.GetManager().HasRunning() for that.
- // clampUint64ToInt64 saturates at math.MaxInt64 instead of wrapping negative,
- // for a live uint64 byte counter (amneziawgnet's own UAPI-dump snapshot, not
- // a DB-accumulated total) going into an int64 API field -- unreachable in
- // practice at real traffic volumes, but a silent negative value would be
- // worse than a saturated one if it were ever hit.
- func clampUint64ToInt64(v uint64) int64 {
- if v > math.MaxInt64 {
- return math.MaxInt64
- }
- return int64(v)
- }
- func amneziawgLogActivity() []PeerActivity {
- var inbounds []*model.Inbound
- if err := database.GetDB().
- Where("protocol = ? AND enable = ? AND node_id IS NULL", model.AmneziaWG, true).
- Find(&inbounds).Error; err != nil {
- logger.Warning("amneziawg logs: list inbounds failed:", err)
- return nil
- }
- now := time.Now()
- var out []PeerActivity
- for _, inbound := range inbounds {
- inst, ok := amneziawg.InstanceFromInbound(inbound)
- if !ok {
- continue
- }
- diag := amneziawgnet.Diagnose(inbound.Id, inst.Peers)
- if !diag.Running {
- continue
- }
- for _, cd := range diag.Clients {
- var handshakeMs int64
- online := false
- if !cd.LastHandshake.IsZero() {
- handshakeMs = cd.LastHandshake.UnixMilli()
- online = now.Sub(cd.LastHandshake) < amneziawgOnlineWindow
- }
- out = append(out, PeerActivity{
- Interface: inst.InterfaceName,
- Tag: inbound.Tag,
- InboundId: inbound.Id,
- Email: cd.Email,
- Endpoint: cd.Endpoint,
- AllowedIPs: cd.AllowedIPs,
- Handshake: handshakeMs,
- Up: clampUint64ToInt64(cd.RxBytes),
- Down: clampUint64ToInt64(cd.TxBytes),
- Online: online,
- })
- }
- }
- slices.SortFunc(out, func(a, b PeerActivity) int {
- if a.Handshake != b.Handshake {
- return cmp.Compare(b.Handshake, a.Handshake)
- }
- return strings.Compare(a.Email, b.Email)
- })
- return out
- }
- // GetAmneziaWGLogs returns at most count peer rows and count event lines,
- // optionally narrowed to rows whose text contains filter (case-insensitive),
- // mirroring GetXrayLogs' own count+filter contract.
- func (s *ServerService) GetAmneziaWGLogs(count string, filter string) *AmneziaWGLogs {
- limit, err := strconv.Atoi(count)
- if err != nil || limit < 1 || limit > 10000 {
- limit = 100
- }
- needle := strings.ToLower(strings.TrimSpace(filter))
- logs := &AmneziaWGLogs{Peers: []PeerActivity{}, Events: []string{}, Running: amneziawgnet.GetManager().HasRunning()}
- for _, peer := range amneziawgLogActivity() {
- if len(logs.Peers) >= limit {
- break
- }
- if needle != "" && !strings.Contains(strings.ToLower(peer.Email+" "+peer.Tag+" "+peer.Interface+" "+peer.Endpoint+" "+peer.AllowedIPs), needle) {
- continue
- }
- logs.Peers = append(logs.Peers, peer)
- }
- for _, line := range logger.GetLogs(10000, "debug") {
- if len(logs.Events) >= limit {
- break
- }
- if !strings.Contains(strings.ToLower(line), amneziawgEventMarker) {
- continue
- }
- if needle != "" && !strings.Contains(strings.ToLower(line), needle) {
- continue
- }
- logs.Events = append(logs.Events, line)
- }
- return logs
- }
- func (s *ServerService) GetXrayLogs(
- count string,
- filter string,
- showDirect string,
- showBlocked string,
- showProxy string,
- freedoms []string,
- blackholes []string,
- ) []LogEntry {
- const (
- Direct = iota
- Blocked
- Proxied
- )
- countInt, _ := strconv.Atoi(count)
- var entries []LogEntry
- pathToAccessLog, err := xray.GetAccessLogPath()
- if err != nil {
- return nil
- }
- file, err := os.Open(pathToAccessLog)
- if err != nil {
- return nil
- }
- defer file.Close()
- scanner := bufio.NewScanner(file)
- for scanner.Scan() {
- line := strings.TrimSpace(scanner.Text())
- if line == "" || strings.Contains(line, "api -> api") {
- // skipping empty lines and api calls
- continue
- }
- if filter != "" && !strings.Contains(line, filter) {
- // applying filter if it's not empty
- continue
- }
- entry := parseAccessLogFields(line)
- if logEntryContains(line, freedoms) {
- if showDirect == "false" {
- continue
- }
- entry.Event = Direct
- } else if logEntryContains(line, blackholes) {
- if showBlocked == "false" {
- continue
- }
- entry.Event = Blocked
- } else {
- if showProxy == "false" {
- continue
- }
- entry.Event = Proxied
- }
- entries = append(entries, entry)
- }
- if err := scanner.Err(); err != nil {
- return nil
- }
- if len(entries) > countInt {
- entries = entries[len(entries)-countInt:]
- }
- return entries
- }
- // 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
- }
- func logEntryContains(line string, suffixes []string) bool {
- for _, sfx := range suffixes {
- if strings.Contains(line, sfx+"]") {
- return true
- }
- }
- return false
- }
- func (s *ServerService) GetConfigJson() (any, error) {
- config, err := s.xrayService.GetXrayConfig()
- if err != nil {
- return nil, err
- }
- contents, err := json.MarshalIndent(config, "", " ")
- if err != nil {
- return nil, err
- }
- var jsonData any
- err = json.Unmarshal(contents, &jsonData)
- if err != nil {
- return nil, err
- }
- return jsonData, nil
- }
- func (s *ServerService) GetDb() ([]byte, error) {
- 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
- }
- // IsValidGeofileName validates that the filename is safe for geofile operations.
- // It checks for path traversal attempts and ensures the filename contains only safe characters.
- func (s *ServerService) IsValidGeofileName(filename string) bool {
- if filename == "" {
- return false
- }
- // Check for path traversal attempts
- if strings.Contains(filename, "..") {
- return false
- }
- // Check for path separators (both forward and backward slash)
- if strings.ContainsAny(filename, `/\`) {
- return false
- }
- // Check for absolute path indicators
- if filepath.IsAbs(filename) {
- return false
- }
- // Additional security: only allow alphanumeric, dots, underscores, and hyphens
- // This is stricter than the general filename regex
- validGeofilePattern := `^[a-zA-Z0-9._-]+\.dat$`
- matched, _ := regexp.MatchString(validGeofilePattern, filename)
- return matched
- }
- // Repo is the upstream release base and Asset the name it publishes under; all
- // three publish "geoip.dat", so only FileName tells the local copies apart.
- type geofileEntry struct {
- Repo string
- Asset string
- FileName string
- }
- var geofileAllowlist = map[string]geofileEntry{
- "geoip.dat": {"https://github.com/Loyalsoldier/v2ray-rules-dat", "geoip.dat", "geoip.dat"},
- "geosite.dat": {"https://github.com/Loyalsoldier/v2ray-rules-dat", "geosite.dat", "geosite.dat"},
- "geoip_IR.dat": {"https://github.com/chocolate4u/Iran-v2ray-rules", "geoip.dat", "geoip_IR.dat"},
- "geosite_IR.dat": {"https://github.com/chocolate4u/Iran-v2ray-rules", "geosite.dat", "geosite_IR.dat"},
- "geoip_RU.dat": {"https://github.com/runetfreedom/russia-v2ray-rules-dat", "geoip.dat", "geoip_RU.dat"},
- "geosite_RU.dat": {"https://github.com/runetfreedom/russia-v2ray-rules-dat", "geosite.dat", "geosite_RU.dat"},
- }
- func (entry geofileEntry) latestURL() string {
- return entry.Repo + "/releases/latest/download/" + entry.Asset
- }
- func (entry geofileEntry) taggedURL(tag string) string {
- return entry.Repo + "/releases/download/" + tag + "/" + entry.Asset
- }
- // stagedGeofile is a verified download waiting to be moved into the asset folder.
- type stagedGeofile struct {
- destPath string
- stagePath string
- }
- // restartXrayAfterGeofileUpdate is a seam: tests assert that an update which
- // installed nothing also restarted nothing.
- var restartXrayAfterGeofileUpdate = (*ServerService).RestartXrayService
- func (s *ServerService) UpdateGeofile(fileName string) error {
- // Strict allowlist check to avoid writing uncontrolled files
- if fileName != "" {
- if _, ok := geofileAllowlist[fileName]; !ok {
- return common.NewErrorf("Invalid geofile name: %q not in allowlist", fileName)
- }
- }
- wanted := geofileAllowlist
- if fileName != "" {
- wanted = map[string]geofileEntry{fileName: geofileAllowlist[fileName]}
- }
- // Atomic per upstream, not across all six: one release's databases belong
- // together, but a failing repo must not discard another repo's good files.
- byRepo := make(map[string][]geofileEntry, len(wanted))
- for _, entry := range wanted {
- byRepo[entry.Repo] = append(byRepo[entry.Repo], entry)
- }
- repos := slices.Sorted(maps.Keys(byRepo))
- binFolder := config.GetBinFolderPath()
- stageDir, err := os.MkdirTemp(binFolder, "geofile-")
- if err != nil {
- return common.NewErrorf("Failed to create staging folder for Geofiles: %v", err)
- }
- defer os.RemoveAll(stageDir)
- client := s.settingService.NewProxiedHTTPClient(0)
- var errorMessages []string
- installed := 0
- for _, repo := range repos {
- entries := byRepo[repo]
- slices.SortFunc(entries, func(a, b geofileEntry) int { return strings.Compare(a.FileName, b.FileName) })
- staged, err := s.stageGeofileRelease(client, entries, binFolder, stageDir)
- if err != nil {
- errorMessages = append(errorMessages, err.Error())
- continue
- }
- for _, file := range staged {
- if err := os.Rename(file.stagePath, file.destPath); err != nil {
- errorMessages = append(errorMessages, fmt.Sprintf("Failed to install Geofile %s: %v", file.destPath, err))
- continue
- }
- installed++
- }
- }
- // Nothing changed, so there is no reason to restart the core and drop every
- // client connection.
- if installed > 0 {
- if err := restartXrayAfterGeofileUpdate(s); err != nil {
- errorMessages = append(errorMessages, fmt.Sprintf("Updated Geofiles but Failed to start Xray: %v", err))
- }
- }
- if len(errorMessages) > 0 {
- return common.NewErrorf("%s", strings.Join(errorMessages, "\r\n"))
- }
- return nil
- }
- // stageGeofileRelease downloads one upstream's databases and verifies each
- // against a digest from the same release, staging all of them or none.
- func (s *ServerService) stageGeofileRelease(client *http.Client, entries []geofileEntry, binFolder, stageDir string) ([]stagedGeofile, error) {
- // Resolve "latest" once. These upstreams publish several times a day, and a
- // release landing mid-batch would check one release's digest against another's bytes.
- tag, err := resolveGeofileTag(client, entries[0].latestURL())
- if err != nil {
- return nil, common.NewErrorf("Error resolving Geofile release from %s: %v", entries[0].Repo, err)
- }
- var staged []stagedGeofile
- for _, entry := range entries {
- destPath := filepath.Join(binFolder, entry.FileName)
- stagePath := filepath.Join(stageDir, entry.FileName)
- changed, err := s.stageGeofile(client, entry, tag, destPath, stagePath)
- if err != nil {
- return nil, common.NewErrorf("Error downloading Geofile '%s': %v", entry.FileName, err)
- }
- if changed {
- staged = append(staged, stagedGeofile{destPath: destPath, stagePath: stagePath})
- }
- }
- return staged, nil
- }
- // resolveGeofileTag reads the immutable release tag a `latest` download
- // redirects to, so the asset and its digest cannot come from two releases.
- func resolveGeofileTag(client *http.Client, latestURL string) (string, error) {
- pinned := *client
- pinned.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
- req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, latestURL, nil)
- if err != nil {
- return "", err
- }
- resp, err := pinned.Do(req)
- if err != nil {
- return "", err
- }
- defer resp.Body.Close()
- _, _ = io.Copy(io.Discard, resp.Body)
- location := resp.Header.Get("Location")
- if location == "" {
- return "", common.NewErrorf("expected a redirect to a tagged release, got HTTP %d", resp.StatusCode)
- }
- return geofileTagFromLocation(location)
- }
- // geofileTagFromLocation pulls <tag> out of a .../releases/download/<tag>/<asset>
- // redirect target.
- func geofileTagFromLocation(location string) (string, error) {
- const marker = "/releases/download/"
- idx := strings.Index(location, marker)
- if idx < 0 {
- return "", common.NewErrorf("unexpected release redirect %q", location)
- }
- tag, _, found := strings.Cut(location[idx+len(marker):], "/")
- if !found || tag == "" {
- return "", common.NewErrorf("unexpected release redirect %q", location)
- }
- return tag, nil
- }
- // stageGeofile downloads one database into stagePath and checks it against the
- // SHA-256 its upstream publishes. It reports false on 304, staging nothing.
- func (s *ServerService) stageGeofile(client *http.Client, entry geofileEntry, tag, destPath, stagePath string) (bool, error) {
- assetURL := entry.taggedURL(tag)
- req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, assetURL, nil)
- if err != nil {
- return false, common.NewErrorf("Failed to create HTTP request for %s: %v", assetURL, err)
- }
- if fileInfo, err := os.Stat(destPath); err == nil {
- if localFileModTime := fileInfo.ModTime(); !localFileModTime.IsZero() {
- req.Header.Set("If-Modified-Since", localFileModTime.UTC().Format(http.TimeFormat))
- }
- }
- resp, err := client.Do(req)
- if err != nil {
- return false, common.NewErrorf("Failed to download Geofile from %s: %v", assetURL, err)
- }
- defer resp.Body.Close()
- // Parse Last-Modified header from server
- var serverModTime time.Time
- if serverModTimeStr := resp.Header.Get("Last-Modified"); serverModTimeStr != "" {
- parsedTime, err := time.Parse(http.TimeFormat, serverModTimeStr)
- if err != nil {
- logger.Warningf("Failed to parse Last-Modified header for %s: %v", assetURL, err)
- } else {
- serverModTime = parsedTime
- }
- }
- // The conditional GET above reads this back, so it must survive the rename.
- setModTime := func(target string) {
- if !serverModTime.IsZero() {
- if err := os.Chtimes(target, serverModTime, serverModTime); err != nil {
- logger.Warningf("Failed to update modification time for %s: %v", target, err)
- }
- }
- }
- // Handle 304 Not Modified
- if resp.StatusCode == http.StatusNotModified {
- setModTime(destPath)
- return false, nil
- }
- if resp.StatusCode != http.StatusOK {
- return false, common.NewErrorf("Failed to download Geofile from %s: received status code %d", assetURL, resp.StatusCode)
- }
- file, err := os.Create(stagePath)
- if err != nil {
- return false, common.NewErrorf("Failed to create Geofile %s: %v", stagePath, err)
- }
- hasher := sha256.New()
- if _, err := io.Copy(io.MultiWriter(file, hasher), resp.Body); err != nil {
- file.Close()
- return false, common.NewErrorf("Failed to save Geofile %s: %v", stagePath, err)
- }
- if err := file.Close(); err != nil {
- return false, common.NewErrorf("Failed to save Geofile %s: %v", stagePath, err)
- }
- // TLS protects the transport, not the artifact. Xray parses these databases
- // when it builds its routing matchers, so a bad one takes the core down.
- want, err := s.fetchGeofileDigest(client, assetURL+".sha256sum", entry.Asset)
- if err != nil {
- return false, err
- }
- if got := hex.EncodeToString(hasher.Sum(nil)); !strings.EqualFold(got, want) {
- return false, common.NewErrorf("does not match the published SHA-256 checksum, so the download is corrupted or has been tampered with (expected %s, got %s)", want, got)
- }
- setModTime(stagePath)
- return true, nil
- }
- // fetchGeofileDigest downloads the .sha256sum sidecar published beside a geo
- // database and returns the digest it lists for assetName.
- func (s *ServerService) fetchGeofileDigest(client *http.Client, sumsURL, assetName string) (string, error) {
- req, reqErr := http.NewRequestWithContext(context.Background(), http.MethodGet, sumsURL, nil)
- if reqErr != nil {
- return "", fmt.Errorf("download geofile checksum: %w", reqErr)
- }
- resp, err := client.Do(req)
- if err != nil {
- return "", fmt.Errorf("download geofile checksum: %w", err)
- }
- defer resp.Body.Close()
- if resp.StatusCode != http.StatusOK {
- return "", fmt.Errorf("download geofile checksum: unexpected HTTP %d", resp.StatusCode)
- }
- raw, err := io.ReadAll(io.LimitReader(resp.Body, maxXrayDigestBytes))
- if err != nil {
- return "", fmt.Errorf("download geofile checksum: %w", err)
- }
- return parseGeofileDigest(raw, assetName)
- }
- // parseGeofileDigest returns the SHA-256 hex a sidecar lists for assetName,
- // matching on base name since upstreams record "geoip.dat" or "release/geoip.dat".
- func parseGeofileDigest(sums []byte, assetName string) (string, error) {
- for line := range strings.SplitSeq(string(sums), "\n") {
- fields := strings.Fields(line)
- if len(fields) != 2 {
- continue
- }
- // A leading "*" is sha256sum's own binary-mode marker, not part of the name.
- if path.Base(strings.TrimPrefix(fields[1], "*")) != assetName {
- continue
- }
- digest := strings.ToLower(fields[0])
- if _, err := hex.DecodeString(digest); err != nil || len(digest) != sha256.Size*2 {
- return "", fmt.Errorf("geofile checksum: malformed SHA-256 entry for %s", assetName)
- }
- return digest, nil
- }
- return "", fmt.Errorf("geofile checksum: no SHA-256 entry for %s", assetName)
- }
- // 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
- }
- func (s *ServerService) GetNewX25519Cert() (any, error) {
- // Run the command
- cmd := exec.CommandContext(context.Background(), xray.GetBinaryPath(), "x25519")
- var out bytes.Buffer
- cmd.Stdout = &out
- err := cmd.Run()
- if err != nil {
- return nil, err
- }
- privateKey, publicKey, err := parseXrayKeyPairOutput(out.String())
- if err != nil {
- return nil, err
- }
- keyPair := map[string]any{
- "privateKey": privateKey,
- "publicKey": publicKey,
- }
- return keyPair, nil
- }
- func (s *ServerService) GetNewmldsa65() (*MLDSA65Response, 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 := &MLDSA65Response{
- 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() (*NewUUIDResponse, error) {
- newUUID, err := uuid.NewRandom()
- if err != nil {
- return nil, fmt.Errorf("failed to generate UUID: %w", err)
- }
- return &NewUUIDResponse{
- UUID: newUUID.String(),
- }, nil
- }
- func (s *ServerService) GetNewmlkem768() (*MLKEM768Response, 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 := &MLKEM768Response{
- Seed: seed,
- Client: client,
- }
- return keyPair, nil
- }
|