From d97ce19f30d0da2e4f8e51f94cc940c856c3e845 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Mon, 18 May 2026 22:57:31 +0200 Subject: [PATCH] fix(windows): clean shutdown, working panel restart, harden kernel32 load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three Windows-specific issues addressed: 1. Orphaned xray-windows-amd64 after VS Code debugger stop. Delve's "Stop" sends TerminateProcess to the Go binary, which is uncatchable — our signal handlers never run, so xrayService.StopXray() is skipped and xray is left dangling. Spawn xray as a child of a Job Object with JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE so the OS kills xray when our handle to the job is closed (which happens even on TerminateProcess). Also trap os.Interrupt in main so Ctrl+C in the terminal runs the graceful path. 2. /panel/setting/restartPanel logged "failed to send SIGHUP signal: not supported by windows" because Windows can't deliver arbitrary signals. Add a restart hook in web/global; main registers it to push SIGHUP into its own signal channel, and RestartPanel calls the hook before falling back to the (Unix-only) signal path. Same restart-loop code runs in both cases. 3. util/sys/sys_windows.go now uses windows.NewLazySystemDLL so the kernel32.dll resolve is pinned to %SystemRoot%\System32 (prevents DLL hijacking by a planted DLL next to the binary). Local filetime type replaced with windows.Filetime, and the unreliable syscall.GetLastError() fallback replaced with a type assertion on the errno captured at call time. Co-Authored-By: Claude Opus 4.7 --- main.go | 8 ++++- util/sys/sys_windows.go | 35 +++++++++------------- web/global/global.go | 25 ++++++++++++++++ web/service/panel.go | 18 +++++++---- xray/process.go | 2 ++ xray/process_other.go | 7 +++++ xray/process_windows.go | 66 +++++++++++++++++++++++++++++++++++++++++ 7 files changed, 134 insertions(+), 27 deletions(-) create mode 100644 xray/process_other.go create mode 100644 xray/process_windows.go diff --git a/main.go b/main.go index f080d573..02753f1d 100644 --- a/main.go +++ b/main.go @@ -73,7 +73,13 @@ func runWebServer() { sigCh := make(chan os.Signal, 1) // Trap shutdown signals - signal.Notify(sigCh, syscall.SIGHUP, syscall.SIGTERM, sys.SIGUSR1) + signal.Notify(sigCh, syscall.SIGHUP, syscall.SIGTERM, sys.SIGUSR1, os.Interrupt) + global.SetRestartHook(func() { + select { + case sigCh <- syscall.SIGHUP: + default: + } + }) for { sig := <-sigCh diff --git a/util/sys/sys_windows.go b/util/sys/sys_windows.go index 008a0466..774f9c02 100644 --- a/util/sys/sys_windows.go +++ b/util/sys/sys_windows.go @@ -9,6 +9,7 @@ import ( "unsafe" "github.com/shirou/gopsutil/v4/net" + "golang.org/x/sys/windows" ) var SIGUSR1 = syscall.Signal(0) @@ -18,7 +19,6 @@ func GetConnectionCount(proto string) (int, error) { if proto != "tcp" && proto != "udp" { return 0, errors.New("invalid protocol") } - stats, err := net.Connections(proto) if err != nil { return 0, err @@ -39,7 +39,9 @@ func GetUDPCount() (int, error) { // --- CPU Utilization (Windows native) --- var ( - modKernel32 = syscall.NewLazyDLL("kernel32.dll") + // NewLazySystemDLL forces the load from %SystemRoot%\System32 so a + // kernel32.dll planted next to the binary can't hijack the call. + modKernel32 = windows.NewLazySystemDLL("kernel32.dll") procGetSystemTimes = modKernel32.NewProc("GetSystemTimes") cpuMu sync.Mutex @@ -49,32 +51,25 @@ var ( hasLast bool ) -type filetime struct { - LowDateTime uint32 - HighDateTime uint32 -} - -// ftToUint64 converts a Windows FILETIME-like struct to a uint64 for -// arithmetic and delta calculations used by CPUPercentRaw. -func ftToUint64(ft filetime) uint64 { +func ftToUint64(ft windows.Filetime) uint64 { return (uint64(ft.HighDateTime) << 32) | uint64(ft.LowDateTime) } -// CPUPercentRaw returns the instantaneous total CPU utilization percentage using -// Windows GetSystemTimes across all logical processors. The first call returns 0 -// as it initializes the baseline. Subsequent calls compute deltas. +// CPUPercentRaw returns instantaneous total CPU utilization across all +// logical processors via Windows GetSystemTimes. The first call returns 0 +// while it initializes the baseline; subsequent calls compute deltas. func CPUPercentRaw() (float64, error) { - var idleFT, kernelFT, userFT filetime + var idleFT, kernelFT, userFT windows.Filetime r1, _, e1 := procGetSystemTimes.Call( uintptr(unsafe.Pointer(&idleFT)), uintptr(unsafe.Pointer(&kernelFT)), uintptr(unsafe.Pointer(&userFT)), ) - if r1 == 0 { // failure - if e1 != nil { - return 0, e1 + if r1 == 0 { + if errno, _ := e1.(syscall.Errno); errno != 0 { + return 0, errno } - return 0, syscall.GetLastError() + return 0, errors.New("GetSystemTimes failed") } idle := ftToUint64(idleFT) @@ -96,7 +91,6 @@ func CPUPercentRaw() (float64, error) { kernelDelta := kernel - lastKernel userDelta := user - lastUser - // Update for next call lastIdle = idle lastKernel = kernel lastUser = user @@ -105,11 +99,10 @@ func CPUPercentRaw() (float64, error) { if total == 0 { return 0, nil } - // On Windows, kernel time includes idle time; busy = total - idle + // kernel time includes idle on Windows; busy = total - idle busy := total - idleDelta pct := float64(busy) / float64(total) * 100.0 - // lower bound not needed; ratios of uint64 are non-negative if pct > 100 { pct = 100 } diff --git a/web/global/global.go b/web/global/global.go index 5556b486..89bd52c1 100644 --- a/web/global/global.go +++ b/web/global/global.go @@ -3,6 +3,7 @@ package global import ( "context" + "sync" _ "unsafe" "github.com/robfig/cron/v3" @@ -11,6 +12,9 @@ import ( var ( webServer WebServer subServer SubServer + + restartHookMu sync.RWMutex + restartHook func() ) // WebServer interface defines methods for accessing the web server instance. @@ -44,3 +48,24 @@ func SetSubServer(s SubServer) { func GetSubServer() SubServer { return subServer } + +// SetRestartHook registers a callback that triggers an in-process panel +// restart. main.go sets this up to push SIGHUP into its own signal channel +// so the restart path works on Windows (where p.Signal(SIGHUP) is unsupported). +func SetRestartHook(fn func()) { + restartHookMu.Lock() + defer restartHookMu.Unlock() + restartHook = fn +} + +// TriggerRestart fires the registered restart hook. Returns false if none is set. +func TriggerRestart() bool { + restartHookMu.RLock() + fn := restartHook + restartHookMu.RUnlock() + if fn == nil { + return false + } + fn() + return true +} diff --git a/web/service/panel.go b/web/service/panel.go index a5480677..b776a214 100644 --- a/web/service/panel.go +++ b/web/service/panel.go @@ -16,6 +16,7 @@ import ( "github.com/mhsanaei/3x-ui/v3/config" "github.com/mhsanaei/3x-ui/v3/logger" + "github.com/mhsanaei/3x-ui/v3/web/global" ) // PanelService provides business logic for panel management operations. @@ -35,14 +36,21 @@ const ( ) func (s *PanelService) RestartPanel(delay time.Duration) error { - p, err := os.FindProcess(syscall.Getpid()) - if err != nil { - return err - } go func() { time.Sleep(delay) - err := p.Signal(syscall.SIGHUP) + if global.TriggerRestart() { + return + } + if runtime.GOOS == "windows" { + logger.Error("panel restart: no restart hook registered (SIGHUP unsupported on Windows)") + return + } + p, err := os.FindProcess(syscall.Getpid()) if err != nil { + logger.Error("panel restart: FindProcess failed:", err) + return + } + if err := p.Signal(syscall.SIGHUP); err != nil { logger.Error("failed to send SIGHUP signal:", err) } }() diff --git a/xray/process.go b/xray/process.go index f1a1400a..83e84135 100644 --- a/xray/process.go +++ b/xray/process.go @@ -368,6 +368,8 @@ func (p *process) startCommand(cmd *exec.Cmd) error { return err } + attachChildLifetime(cmd) + go p.waitForCommand(cmd) return nil } diff --git a/xray/process_other.go b/xray/process_other.go new file mode 100644 index 00000000..71208617 --- /dev/null +++ b/xray/process_other.go @@ -0,0 +1,7 @@ +//go:build !windows + +package xray + +import "os/exec" + +func attachChildLifetime(_ *exec.Cmd) {} diff --git a/xray/process_windows.go b/xray/process_windows.go new file mode 100644 index 00000000..ab04f3f8 --- /dev/null +++ b/xray/process_windows.go @@ -0,0 +1,66 @@ +//go:build windows + +package xray + +import ( + "os/exec" + "sync" + "unsafe" + + "github.com/mhsanaei/3x-ui/v3/logger" + "golang.org/x/sys/windows" +) + +var ( + killOnExitJobOnce sync.Once + killOnExitJob windows.Handle + killOnExitJobErr error +) + +func ensureKillOnExitJob() (windows.Handle, error) { + killOnExitJobOnce.Do(func() { + h, err := windows.CreateJobObject(nil, nil) + if err != nil { + killOnExitJobErr = err + return + } + info := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{ + BasicLimitInformation: windows.JOBOBJECT_BASIC_LIMIT_INFORMATION{ + LimitFlags: windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + }, + } + _, err = windows.SetInformationJobObject( + h, + windows.JobObjectExtendedLimitInformation, + uintptr(unsafe.Pointer(&info)), + uint32(unsafe.Sizeof(info)), + ) + if err != nil { + windows.CloseHandle(h) + killOnExitJobErr = err + return + } + killOnExitJob = h + }) + return killOnExitJob, killOnExitJobErr +} + +func attachChildLifetime(cmd *exec.Cmd) { + if cmd == nil || cmd.Process == nil { + return + } + job, err := ensureKillOnExitJob() + if err != nil { + logger.Warning("xray: kill-on-exit job unavailable:", err) + return + } + h, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, false, uint32(cmd.Process.Pid)) + if err != nil { + logger.Warning("xray: OpenProcess for job attach failed:", err) + return + } + defer windows.CloseHandle(h) + if err := windows.AssignProcessToJobObject(job, h); err != nil { + logger.Warning("xray: AssignProcessToJobObject failed:", err) + } +}