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)
// 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

View file

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

View file

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

View file

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

View file

@ -368,6 +368,8 @@ func (p *process) startCommand(cmd *exec.Cmd) error {
return err
}
attachChildLifetime(cmd)
go p.waitForCommand(cmd)
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)
}
}