From f76f07efabdce7a86e97737f02890703171fa13f Mon Sep 17 00:00:00 2001 From: Dev Scribe Date: Thu, 22 Oct 2026 11:04:39 -0400 Subject: [PATCH] fix(windows): load WTSGetActiveConsoleSessionId from kernel32 getActiveConsoleUser looked up WTSGetActiveConsoleSessionId in wtsapi32.dll. Windows exports it from kernel32.dll, so Find failed on every host and console-session discovery always returned no user. Previously, the registry fallbacks hid this. Daemon start now uses session-only discovery, so on Windows every managed start omitted username even with a user signed in at the console. Use windows.WTSGetActiveConsoleSessionId from golang.org/x/sys, which binds the kernel32 export. Add a Windows test that compares discoverSessionUser with the active console row of quser. --- discover_user_windows.go | 17 +++++------- discover_user_windows_test.go | 49 +++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 11 deletions(-) create mode 100644 discover_user_windows_test.go diff --git a/discover_user_windows.go b/discover_user_windows.go index ed54fc3..f9d9993 100644 --- a/discover_user_windows.go +++ b/discover_user_windows.go @@ -14,10 +14,9 @@ import ( ) var ( - wtsapi32 = windows.NewLazySystemDLL("wtsapi32.dll") - procWTSGetActiveConsoleSessionId = wtsapi32.NewProc("WTSGetActiveConsoleSessionId") - procWTSQuerySessionInformation = wtsapi32.NewProc("WTSQuerySessionInformationW") - procWTSFreeMemory = wtsapi32.NewProc("WTSFreeMemory") + wtsapi32 = windows.NewLazySystemDLL("wtsapi32.dll") + procWTSQuerySessionInformation = wtsapi32.NewProc("WTSQuerySessionInformationW") + procWTSFreeMemory = wtsapi32.NewProc("WTSFreeMemory") ) const ( @@ -75,12 +74,8 @@ func discoverSessionUser(ctx context.Context) string { // getActiveConsoleUser gets the username of the active console session func getActiveConsoleUser(ctx context.Context) string { - // Guard against missing WTS procedures (e.g., Windows Server Core). - if err := procWTSGetActiveConsoleSessionId.Find(); err != nil { - ProxyLogger.Load().Debug().Err(err).Msg("WTSGetActiveConsoleSessionId not available, skipping console session check") - return "" - } - sessionId, _, _ := procWTSGetActiveConsoleSessionId.Call() + // WTSGetActiveConsoleSessionId is exported by kernel32.dll, not wtsapi32.dll. + sessionId := windows.WTSGetActiveConsoleSessionId() if sessionId == 0xFFFFFFFF { // Invalid session ProxyLogger.Load().Debug().Msg("no active console session found") return "" @@ -95,7 +90,7 @@ func getActiveConsoleUser(ctx context.Context) string { } ret, _, _ := procWTSQuerySessionInformation.Call( 0, // WTS_CURRENT_SERVER_HANDLE - sessionId, + uintptr(sessionId), uintptr(WTSUserName), uintptr(unsafe.Pointer(&buffer)), uintptr(unsafe.Pointer(&bytesReturned)), diff --git a/discover_user_windows_test.go b/discover_user_windows_test.go new file mode 100644 index 0000000..7fdaaca --- /dev/null +++ b/discover_user_windows_test.go @@ -0,0 +1,49 @@ +//go:build windows + +package ctrld + +import ( + "bufio" + "bytes" + "context" + "os/exec" + "strings" + "testing" +) + +// Every procedure that console-session discovery loads at runtime must exist in +// the DLL it is loaded from. A wrong DLL makes Find fail on every host, so +// discovery silently reports no user instead of the signed-in one. +func TestConsoleSessionProceduresResolve(t *testing.T) { + for _, p := range []interface { + Find() error + }{procWTSQuerySessionInformation, procWTSFreeMemory} { + if err := p.Find(); err != nil { + t.Fatalf("console session procedure does not resolve: %v", err) + } + } +} + +// discoverSessionUser must agree with Windows' own view of the console session: +// the user shown on the active "console" row of `query user`, or no user when +// that row is absent. +func TestDiscoverSessionUserMatchesConsoleSession(t *testing.T) { + quser, err := exec.LookPath("quser.exe") + if err != nil { + t.Skip("quser.exe not available") + } + // quser exits 1 when no session exists; its output still answers the question. + out, _ := exec.Command(quser).CombinedOutput() + want := "" + sc := bufio.NewScanner(bytes.NewReader(out)) + for sc.Scan() { + f := strings.Fields(strings.TrimPrefix(sc.Text(), ">")) + if len(f) >= 4 && strings.EqualFold(f[1], "console") && strings.EqualFold(f[3], "Active") { + want = f[0] + } + } + got := discoverSessionUser(context.Background()) + if !strings.EqualFold(got, want) { + t.Fatalf("discoverSessionUser() = %q, want %q (quser output: %q)", got, want, out) + } +}