fix(windows): clean shutdown, working panel restart, harden kernel32 load

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 <noreply@anthropic.com>
This commit is contained in:
MHSanaei 2026-05-18 22:57:31 +02:00
parent 072d266f50
commit d97ce19f30
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
7 changed files with 134 additions and 27 deletions

View file

@ -73,7 +73,13 @@ func runWebServer() {
sigCh := make(chan os.Signal, 1) sigCh := make(chan os.Signal, 1)
// Trap shutdown signals // 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 { for {
sig := <-sigCh sig := <-sigCh

View file

@ -9,6 +9,7 @@ import (
"unsafe" "unsafe"
"github.com/shirou/gopsutil/v4/net" "github.com/shirou/gopsutil/v4/net"
"golang.org/x/sys/windows"
) )
var SIGUSR1 = syscall.Signal(0) var SIGUSR1 = syscall.Signal(0)
@ -18,7 +19,6 @@ func GetConnectionCount(proto string) (int, error) {
if proto != "tcp" && proto != "udp" { if proto != "tcp" && proto != "udp" {
return 0, errors.New("invalid protocol") return 0, errors.New("invalid protocol")
} }
stats, err := net.Connections(proto) stats, err := net.Connections(proto)
if err != nil { if err != nil {
return 0, err return 0, err
@ -39,7 +39,9 @@ func GetUDPCount() (int, error) {
// --- CPU Utilization (Windows native) --- // --- CPU Utilization (Windows native) ---
var ( 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") procGetSystemTimes = modKernel32.NewProc("GetSystemTimes")
cpuMu sync.Mutex cpuMu sync.Mutex
@ -49,32 +51,25 @@ var (
hasLast bool hasLast bool
) )
type filetime struct { func ftToUint64(ft windows.Filetime) uint64 {
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 {
return (uint64(ft.HighDateTime) << 32) | uint64(ft.LowDateTime) return (uint64(ft.HighDateTime) << 32) | uint64(ft.LowDateTime)
} }
// CPUPercentRaw returns the instantaneous total CPU utilization percentage using // CPUPercentRaw returns instantaneous total CPU utilization across all
// Windows GetSystemTimes across all logical processors. The first call returns 0 // logical processors via Windows GetSystemTimes. The first call returns 0
// as it initializes the baseline. Subsequent calls compute deltas. // while it initializes the baseline; subsequent calls compute deltas.
func CPUPercentRaw() (float64, error) { func CPUPercentRaw() (float64, error) {
var idleFT, kernelFT, userFT filetime var idleFT, kernelFT, userFT windows.Filetime
r1, _, e1 := procGetSystemTimes.Call( r1, _, e1 := procGetSystemTimes.Call(
uintptr(unsafe.Pointer(&idleFT)), uintptr(unsafe.Pointer(&idleFT)),
uintptr(unsafe.Pointer(&kernelFT)), uintptr(unsafe.Pointer(&kernelFT)),
uintptr(unsafe.Pointer(&userFT)), uintptr(unsafe.Pointer(&userFT)),
) )
if r1 == 0 { // failure if r1 == 0 {
if e1 != nil { if errno, _ := e1.(syscall.Errno); errno != 0 {
return 0, e1 return 0, errno
} }
return 0, syscall.GetLastError() return 0, errors.New("GetSystemTimes failed")
} }
idle := ftToUint64(idleFT) idle := ftToUint64(idleFT)
@ -96,7 +91,6 @@ func CPUPercentRaw() (float64, error) {
kernelDelta := kernel - lastKernel kernelDelta := kernel - lastKernel
userDelta := user - lastUser userDelta := user - lastUser
// Update for next call
lastIdle = idle lastIdle = idle
lastKernel = kernel lastKernel = kernel
lastUser = user lastUser = user
@ -105,11 +99,10 @@ func CPUPercentRaw() (float64, error) {
if total == 0 { if total == 0 {
return 0, nil 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 busy := total - idleDelta
pct := float64(busy) / float64(total) * 100.0 pct := float64(busy) / float64(total) * 100.0
// lower bound not needed; ratios of uint64 are non-negative
if pct > 100 { if pct > 100 {
pct = 100 pct = 100
} }

View file

@ -3,6 +3,7 @@ package global
import ( import (
"context" "context"
"sync"
_ "unsafe" _ "unsafe"
"github.com/robfig/cron/v3" "github.com/robfig/cron/v3"
@ -11,6 +12,9 @@ import (
var ( var (
webServer WebServer webServer WebServer
subServer SubServer subServer SubServer
restartHookMu sync.RWMutex
restartHook func()
) )
// WebServer interface defines methods for accessing the web server instance. // WebServer interface defines methods for accessing the web server instance.
@ -44,3 +48,24 @@ func SetSubServer(s SubServer) {
func GetSubServer() SubServer { func GetSubServer() SubServer {
return 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
}

View file

@ -16,6 +16,7 @@ import (
"github.com/mhsanaei/3x-ui/v3/config" "github.com/mhsanaei/3x-ui/v3/config"
"github.com/mhsanaei/3x-ui/v3/logger" "github.com/mhsanaei/3x-ui/v3/logger"
"github.com/mhsanaei/3x-ui/v3/web/global"
) )
// PanelService provides business logic for panel management operations. // PanelService provides business logic for panel management operations.
@ -35,14 +36,21 @@ const (
) )
func (s *PanelService) RestartPanel(delay time.Duration) error { func (s *PanelService) RestartPanel(delay time.Duration) error {
p, err := os.FindProcess(syscall.Getpid())
if err != nil {
return err
}
go func() { go func() {
time.Sleep(delay) 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 { 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) logger.Error("failed to send SIGHUP signal:", err)
} }
}() }()

View file

@ -368,6 +368,8 @@ func (p *process) startCommand(cmd *exec.Cmd) error {
return err return err
} }
attachChildLifetime(cmd)
go p.waitForCommand(cmd) go p.waitForCommand(cmd)
return nil return nil
} }

7
xray/process_other.go Normal file
View file

@ -0,0 +1,7 @@
//go:build !windows
package xray
import "os/exec"
func attachChildLifetime(_ *exec.Cmd) {}

66
xray/process_windows.go Normal file
View file

@ -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)
}
}