added Public IP monitoring and fixing issue where the app would stop recording session event until user logout.

This commit is contained in:
ghostersk
2025-05-28 06:16:50 +01:00
parent 795ab2839a
commit 52d20c8f0b
7 changed files with 288 additions and 48 deletions

View File

@@ -1,5 +1,6 @@
# User Session Monitor Agent
- For Windows
- created with AI
## Overview
This application monitors Windows session events (logon, logoff, lock, unlock) and sends them to a specified API endpoint. It's designed to run as a Windows service on any version of Windows, collecting user session activity and securely transmitting it with an API key.

View File

@@ -14,7 +14,8 @@ type EventData struct {
EventType string `json:"EventType"`
UserName string `json:"UserName"`
ComputerName string `json:"ComputerName"`
IPAddress string `json:"IPAddress"`
LocalIP string `json:"LocalIP"`
PublicIP string `json:"PublicIP"`
Timestamp string `json:"Timestamp"`
Retry int `json:"retry,omitempty"`
}
@@ -48,13 +49,14 @@ func NewClient(apiKey, serverURL string) *Client {
}
// SendEvent sends an event to the API
func (c *Client) SendEvent(eventType, username, computerName, ipAddress, timestamp string, retry int) (bool, error) {
func (c *Client) SendEvent(eventType, username, computerName, localIP, publicIP, timestamp string, retry int) (bool, error) {
// Create event data
eventData := EventData{
EventType: eventType,
UserName: username,
ComputerName: computerName,
IPAddress: ipAddress,
LocalIP: localIP,
PublicIP: publicIP,
Timestamp: timestamp,
}

View File

@@ -138,7 +138,6 @@ func (hc *HealthChecker) retryFailedEvents() {
// Create API client for retry
cfg := config.GetConfig()
apiClient := NewClient(cfg.APIKey, cfg.ServerURL)
// Try to send each failed event
successCount := 0
var successfulEvents []map[string]string
@@ -148,7 +147,8 @@ func (hc *HealthChecker) retryFailedEvents() {
event["eventtype"],
event["username"],
event["hostname"],
event["ipaddress"],
event["localip"],
event["publicip"],
event["timestamp"],
1, // Retry flag
)
@@ -166,7 +166,8 @@ func (hc *HealthChecker) retryFailedEvents() {
event["eventtype"],
event["username"],
event["hostname"],
event["ipaddress"],
event["localip"],
event["publicip"],
event["timestamp"],
logging.SendStatusRetrySuccess,
); err != nil {

Binary file not shown.

View File

@@ -39,6 +39,12 @@ const (
DefaultLogRotationSizeMB = 5
)
// DefaultPublicIPHTTPURLs are the default URLs for HTTP-based public IP detection
var DefaultPublicIPHTTPURLs = []string{
"https://ifconfig.me/ip",
"https://ipv4.icanhazip.com",
}
// Config represents the application configuration
type Config struct {
APIKey string
@@ -46,11 +52,12 @@ type Config struct {
DebugLogs bool
Timezone string
InstallDir string
HealthCheckInterval int // Health check interval in minutes (0 = disabled)
HealthCheckPath string // Health check endpoint path
SessionLogRotationSizeMB int // Session monitor log rotation size in MB (0 = no rotation, max 20MB)
ErrorLogRotationSizeMB int // Error log rotation size in MB (0 = no rotation, max 20MB)
EventLogRotationSizeMB int // Event CSV log rotation size in MB (0 = no rotation, max 20MB)
HealthCheckInterval int // Health check interval in minutes (0 = disabled)
HealthCheckPath string // Health check endpoint path
PublicIPHTTPURLs []string // Custom HTTP URLs for public IP detection
SessionLogRotationSizeMB int // Session monitor log rotation size in MB (0 = no rotation, max 20MB)
ErrorLogRotationSizeMB int // Error log rotation size in MB (0 = no rotation, max 20MB)
EventLogRotationSizeMB int // Event CSV log rotation size in MB (0 = no rotation, max 20MB)
mu sync.RWMutex
}
@@ -71,10 +78,12 @@ func GetConfig() *Config {
InstallDir: DefaultInstallDir,
HealthCheckInterval: DefaultHealthCheckInterval,
HealthCheckPath: DefaultHealthCheckPath,
PublicIPHTTPURLs: make([]string, len(DefaultPublicIPHTTPURLs)),
SessionLogRotationSizeMB: DefaultLogRotationSizeMB,
ErrorLogRotationSizeMB: DefaultLogRotationSizeMB,
EventLogRotationSizeMB: DefaultLogRotationSizeMB,
}
copy(instance.PublicIPHTTPURLs, DefaultPublicIPHTTPURLs)
})
return instance
}
@@ -137,6 +146,19 @@ func (c *Config) Load(configFile string) error {
c.HealthCheckInterval = apiSection.Key("health_check_interval").MustInt(DefaultHealthCheckInterval)
c.HealthCheckPath = apiSection.Key("health_check_path").MustString(DefaultHealthCheckPath)
// Load custom public IP HTTP URLs
publicIPURLsStr := apiSection.Key("public_ip_http_urls").MustString("")
if publicIPURLsStr != "" {
c.PublicIPHTTPURLs = strings.Split(publicIPURLsStr, ",")
// Trim whitespace from URLs
for i := range c.PublicIPHTTPURLs {
c.PublicIPHTTPURLs[i] = strings.TrimSpace(c.PublicIPHTTPURLs[i])
}
} else {
c.PublicIPHTTPURLs = make([]string, len(DefaultPublicIPHTTPURLs))
copy(c.PublicIPHTTPURLs, DefaultPublicIPHTTPURLs)
}
// Read Logging section
loggingSection := cfg.Section("Logging")
c.SessionLogRotationSizeMB = loggingSection.Key("session_log_rotation_size_mb").MustInt(DefaultLogRotationSizeMB)
@@ -195,6 +217,19 @@ func (c *Config) LoadFromSource(configFile string) error {
c.HealthCheckInterval = apiSection.Key("health_check_interval").MustInt(DefaultHealthCheckInterval)
c.HealthCheckPath = apiSection.Key("health_check_path").MustString(DefaultHealthCheckPath)
// Load custom public IP HTTP URLs
publicIPURLsStr := apiSection.Key("public_ip_http_urls").MustString("")
if publicIPURLsStr != "" {
c.PublicIPHTTPURLs = strings.Split(publicIPURLsStr, ",")
// Trim whitespace from URLs
for i := range c.PublicIPHTTPURLs {
c.PublicIPHTTPURLs[i] = strings.TrimSpace(c.PublicIPHTTPURLs[i])
}
} else {
c.PublicIPHTTPURLs = make([]string, len(DefaultPublicIPHTTPURLs))
copy(c.PublicIPHTTPURLs, DefaultPublicIPHTTPURLs)
}
// Read Logging section
loggingSection := cfg.Section("Logging")
c.SessionLogRotationSizeMB = loggingSection.Key("session_log_rotation_size_mb").MustInt(DefaultLogRotationSizeMB)
@@ -241,6 +276,7 @@ func (c *Config) Save(configFile string) error {
apiSection.Key("install_dir").SetValue(c.InstallDir)
apiSection.Key("health_check_interval").SetValue(fmt.Sprintf("%d", c.HealthCheckInterval))
apiSection.Key("health_check_path").SetValue(c.HealthCheckPath)
apiSection.Key("public_ip_http_urls").SetValue(strings.Join(c.PublicIPHTTPURLs, ","))
// Create Logging section
loggingSection, err := cfg.NewSection("Logging")
@@ -348,6 +384,7 @@ func (c *Config) createDefaultConfig(configFile string) error {
apiSection.Key("install_dir").SetValue(c.InstallDir)
apiSection.Key("health_check_interval").SetValue(fmt.Sprintf("%d", c.HealthCheckInterval))
apiSection.Key("health_check_path").SetValue(c.HealthCheckPath)
apiSection.Key("public_ip_http_urls").SetValue(strings.Join(c.PublicIPHTTPURLs, ","))
// Create Logging section
loggingSection, err := cfg.NewSection("Logging")

View File

@@ -26,9 +26,10 @@ const (
EventTypeCol = 0
UsernameCol = 1
HostnameCol = 2
IPAddressCol = 3
TimestampCol = 4
SendStatusCol = 5
LocalIPCol = 3
PublicIPCol = 4
TimestampCol = 5
SendStatusCol = 6
// Send status values
SendStatusFailed = 0
@@ -41,8 +42,8 @@ const (
// Headers for CSV files
var (
MainCSVHeaders = []string{"eventtype", "username", "hostname", "ipaddress", "timestamp", "send_status"}
RetryCSVHeaders = []string{"eventtype", "username", "hostname", "ipaddress", "timestamp"}
MainCSVHeaders = []string{"eventtype", "username", "hostname", "localip", "publicip", "timestamp", "send_status"}
RetryCSVHeaders = []string{"eventtype", "username", "hostname", "localip", "publicip", "timestamp"}
)
// NewCSVLogger creates a new CSV logger
@@ -109,7 +110,7 @@ func (l *CSVLogger) initCSVFile(filePath string, headers []string) error {
}
// LogEvent logs an event to the CSV file
func (l *CSVLogger) LogEvent(eventType, username, hostname, ipAddress, timestamp string, sendStatus int) error {
func (l *CSVLogger) LogEvent(eventType, username, hostname, localIP, publicIP, timestamp string, sendStatus int) error {
l.mu.Lock()
defer l.mu.Unlock()
@@ -125,11 +126,11 @@ func (l *CSVLogger) LogEvent(eventType, username, hostname, ipAddress, timestamp
if sendStatus == SendStatusFailed {
// For failed events, use the retry file
filePath = l.retryFile
record = []string{eventType, username, hostname, ipAddress, timestamp}
record = []string{eventType, username, hostname, localIP, publicIP, timestamp}
} else {
// For successful events, use the main file
filePath = l.csvFile
record = []string{eventType, username, hostname, ipAddress, timestamp, fmt.Sprintf("%d", sendStatus)}
record = []string{eventType, username, hostname, localIP, publicIP, timestamp, fmt.Sprintf("%d", sendStatus)}
}
// Open the file in append mode
@@ -236,15 +237,15 @@ func (l *CSVLogger) RemoveSuccessfulRetries(successfulEvents []map[string]string
if err != nil {
return fmt.Errorf("failed to get failed events: %v", err)
}
// Create a map of successful events for quick lookup
successfulMap := make(map[string]bool)
for _, event := range successfulEvents {
key := fmt.Sprintf("%s|%s|%s|%s|%s",
key := fmt.Sprintf("%s|%s|%s|%s|%s|%s",
event["eventtype"],
event["username"],
event["hostname"],
event["ipaddress"],
event["localip"],
event["publicip"],
event["timestamp"])
successfulMap[key] = true
}
@@ -252,11 +253,12 @@ func (l *CSVLogger) RemoveSuccessfulRetries(successfulEvents []map[string]string
// Filter out successful events from the failed events list
var remainingEvents []map[string]string
for _, event := range allFailedEvents {
key := fmt.Sprintf("%s|%s|%s|%s|%s",
key := fmt.Sprintf("%s|%s|%s|%s|%s|%s",
event["eventtype"],
event["username"],
event["hostname"],
event["ipaddress"],
event["localip"],
event["publicip"],
event["timestamp"])
if !successfulMap[key] {
remainingEvents = append(remainingEvents, event)
@@ -280,14 +282,14 @@ func (l *CSVLogger) RemoveSuccessfulRetries(successfulEvents []map[string]string
return fmt.Errorf("failed to open retry file for writing: %v", err)
}
defer file.Close()
writer := csv.NewWriter(file)
for _, event := range remainingEvents {
record := []string{
event["eventtype"],
event["username"],
event["hostname"],
event["ipaddress"],
event["localip"],
event["publicip"],
event["timestamp"],
}
if err := writer.Write(record); err != nil {

View File

@@ -1,9 +1,13 @@
package session
import (
"context"
"fmt"
"io"
"net"
"net/http"
"os"
"strings"
"sync"
"syscall"
"time"
@@ -11,6 +15,7 @@ import (
"unsafe"
"monitoring-agent-win/api"
"monitoring-agent-win/config"
"monitoring-agent-win/logging"
"golang.org/x/sys/windows"
@@ -95,6 +100,10 @@ type Monitor struct {
mu sync.RWMutex
stopChan chan struct{}
hwnd HWND
// Add session notification health monitoring
lastNotificationTime time.Time
healthCheckTicker *time.Ticker
notificationHealthy bool
}
// NewMonitor creates a new session monitor
@@ -187,16 +196,23 @@ func (m *Monitor) Start() error {
}
m.hwnd = HWND(hwnd)
// Register for session notifications
if err := wtsRegisterSessionNotification(m.hwnd, NOTIFY_FOR_ALL_SESSIONS); err != nil {
destroyWindow(m.hwnd)
return fmt.Errorf("failed to register for session notifications: %v", err)
}
// Initialize health monitoring
m.lastNotificationTime = time.Now()
m.notificationHealthy = true
m.healthCheckTicker = time.NewTicker(5 * time.Minute) // Check every 5 minutes
// Start message loop in a goroutine
go m.messageLoop()
// Start health monitoring goroutine
go m.healthMonitor()
return nil
}
@@ -204,6 +220,11 @@ func (m *Monitor) Start() error {
func (m *Monitor) Stop() {
close(m.stopChan)
// Stop health monitoring
if m.healthCheckTicker != nil {
m.healthCheckTicker.Stop()
}
// Unregister session notifications and destroy window
if m.hwnd != 0 {
wtsUnRegisterSessionNotification(m.hwnd)
@@ -269,24 +290,29 @@ func (m *Monitor) messageLoop() {
func (m *Monitor) wndProc(hwnd HWND, msg uint32, wParam, lParam uintptr) uintptr {
switch msg {
case WM_WTSSESSION_CHANGE:
// Capture timestamp immediately when event occurs for accuracy
eventTime := time.Now()
// Update last notification time for health monitoring
m.lastNotificationTime = eventTime
sessionID := uint32(lParam)
eventType := uint32(wParam)
// Get session info
username := m.getSessionUsername(sessionID)
computerName := getComputerName()
ipAddress := getDefaultIPv4()
localIP := getDefaultIPv4()
// Log the event
// Log the event with the captured timestamp
switch eventType {
case WTS_SESSION_LOGON:
m.logEvent(EventLogon, username, computerName, ipAddress)
m.logEventWithTime(EventLogon, username, computerName, localIP, eventTime)
case WTS_SESSION_LOGOFF:
m.logEvent(EventLogoff, username, computerName, ipAddress)
m.logEventWithTime(EventLogoff, username, computerName, localIP, eventTime)
case WTS_SESSION_LOCK:
m.logEvent(EventLock, username, computerName, ipAddress)
m.logEventWithTime(EventLock, username, computerName, localIP, eventTime)
case WTS_SESSION_UNLOCK:
m.logEvent(EventUnlock, username, computerName, ipAddress)
m.logEventWithTime(EventUnlock, username, computerName, localIP, eventTime)
}
}
@@ -332,31 +358,105 @@ func destroyWindow(hwnd HWND) {
proc.Call(uintptr(hwnd))
}
// logEvent logs an event to the CSV file and sends it to the API
func (m *Monitor) logEvent(eventType, username, computerName, ipAddress string) {
// Get current timestamp in the configured timezone
// getPublicIP retrieves public IP with HTTP first, then DNS fallback
func getPublicIP() (string, error) {
// Get config to check for custom HTTP URLs
cfg := config.GetConfig()
// Use custom URLs if configured, otherwise use defaults
urls := []string{
"https://ipv4.icanhazip.com",
"https://ifconfig.me/ip",
}
if len(cfg.PublicIPHTTPURLs) > 0 {
urls = cfg.PublicIPHTTPURLs
}
// Try HTTP services
client := &http.Client{Timeout: 2 * time.Second}
for _, url := range urls {
if resp, err := client.Get(url); err == nil && resp.StatusCode == 200 {
defer resp.Body.Close()
if body, err := io.ReadAll(resp.Body); err == nil {
if ip := strings.TrimSpace(string(body)); net.ParseIP(ip) != nil && net.ParseIP(ip).To4() != nil {
return ip, nil
}
}
}
}
// DNS fallback using Google's nameserver (equivalent to: dig TXT +short o-o.myaddr.l.google.com @ns1.google.com)
// First resolve ns1.google.com to get its IP dynamically
nsIPs, err := net.LookupHost("ns1.google.com")
if err != nil || len(nsIPs) == 0 {
return "", fmt.Errorf("failed to resolve ns1.google.com")
}
// Use the first IPv4 address from ns1.google.com
var nsIP string
for _, ip := range nsIPs {
if net.ParseIP(ip).To4() != nil {
nsIP = ip
break
}
}
if nsIP == "" {
return "", fmt.Errorf("no IPv4 address found for ns1.google.com")
}
resolver := &net.Resolver{
PreferGo: true,
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
d := net.Dialer{Timeout: 2 * time.Second}
return d.DialContext(ctx, network, nsIP+":53")
},
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if txts, err := resolver.LookupTXT(ctx, "o-o.myaddr.l.google.com"); err == nil {
for _, txt := range txts {
if parsedIP := net.ParseIP(txt); parsedIP != nil && parsedIP.To4() != nil {
return txt, nil
}
}
}
return "", fmt.Errorf("failed to retrieve public IP")
}
// logEventWithTime logs an event with a pre-captured timestamp
func (m *Monitor) logEventWithTime(eventType, username, computerName, localIP string, eventTime time.Time) {
// Get public IP (this can be done in background as it's less time-sensitive)
publicIP, err := getPublicIP()
if err != nil {
m.logger.Warning("Failed to get public IP: %v - using 'Unknown'", err)
publicIP = "Unknown"
}
// Format the timestamp using the pre-captured event time
var timestamp string
if m.timezone != "" {
// Load the configured timezone
loc, err := time.LoadLocation(m.timezone)
// Try to load the configured timezone with Windows-specific handling
loc, err := loadTimezoneWithFallback(m.timezone)
if err != nil {
// If timezone loading fails, fall back to local time but log the error
m.logger.Warning("Failed to load timezone '%s': %v - using local time", m.timezone, err)
timestamp = time.Now().Format("2006-01-02T15:04:05-07:00")
timestamp = eventTime.Format("2006-01-02T15:04:05-07:00")
} else {
// Use the configured timezone
timestamp = time.Now().In(loc).Format("2006-01-02T15:04:05-07:00")
timestamp = eventTime.In(loc).Format("2006-01-02T15:04:05-07:00")
}
} else {
// If no timezone configured, use local time
timestamp = time.Now().Format("2006-01-02T15:04:05-07:00")
timestamp = eventTime.Format("2006-01-02T15:04:05-07:00")
}
// Log the event
m.logger.Info("User: %s - Event: %s - Computer: %s - IP: %s", username, eventType, computerName, ipAddress)
m.logger.Info("User: %s - Event: %s - Computer: %s - LocalIP: %s - PublicIP: %s", username, eventType, computerName, localIP, publicIP)
// Send to API
success, err := m.apiClient.SendEvent(eventType, username, computerName, ipAddress, timestamp, 0)
success, err := m.apiClient.SendEvent(eventType, username, computerName, localIP, publicIP, timestamp, 0)
if err != nil {
m.logger.Error("Failed to send event: %v", err)
}
@@ -367,7 +467,7 @@ func (m *Monitor) logEvent(eventType, username, computerName, ipAddress string)
sendStatus = logging.SendStatusFailed
}
if err := m.csvLogger.LogEvent(eventType, username, computerName, ipAddress, timestamp, sendStatus); err != nil {
if err := m.csvLogger.LogEvent(eventType, username, computerName, localIP, publicIP, timestamp, sendStatus); err != nil {
m.logger.Error("Failed to log event to CSV: %v", err)
}
@@ -399,7 +499,8 @@ func (m *Monitor) retryFailedEvents() {
event["eventtype"],
event["username"],
event["hostname"],
event["ipaddress"],
event["localip"],
event["publicip"],
event["timestamp"],
1, // Retry flag
)
@@ -415,7 +516,8 @@ func (m *Monitor) retryFailedEvents() {
event["eventtype"],
event["username"],
event["hostname"],
event["ipaddress"],
event["localip"],
event["publicip"],
event["timestamp"],
logging.SendStatusRetrySuccess,
); err != nil {
@@ -617,3 +719,98 @@ func utf16PtrToString(p *uint16) string {
return string(utf16.Decode(s))
}
// loadTimezoneWithFallback loads timezone with Windows-specific handling
func loadTimezoneWithFallback(timezone string) (*time.Location, error) {
// First try the standard IANA timezone
loc, err := time.LoadLocation(timezone)
if err == nil {
return loc, nil
}
// Map common IANA timezones to Windows timezones
windowsTimezoneMap := map[string]string{
"Europe/London": "GMT Standard Time",
"Europe/Paris": "Romance Standard Time",
"Europe/Berlin": "W. Europe Standard Time",
"Europe/Madrid": "Romance Standard Time",
"Europe/Rome": "W. Europe Standard Time",
"Europe/Amsterdam": "W. Europe Standard Time",
"America/New_York": "Eastern Standard Time",
"America/Chicago": "Central Standard Time",
"America/Denver": "Mountain Standard Time",
"America/Los_Angeles": "Pacific Standard Time",
"Asia/Tokyo": "Tokyo Standard Time",
"Asia/Shanghai": "China Standard Time",
"Asia/Kolkata": "India Standard Time",
"Australia/Sydney": "AUS Eastern Standard Time",
"UTC": "UTC",
}
// Try Windows timezone name
if windowsName, exists := windowsTimezoneMap[timezone]; exists {
loc, err = time.LoadLocation(windowsName)
if err == nil {
return loc, nil
}
}
// If both fail, return the original error
return nil, fmt.Errorf("timezone '%s' not found (tried IANA and Windows formats)", timezone)
}
// reregisterSessionNotifications re-registers for session notifications
// This helps fix the issue where session notifications stop working
func (m *Monitor) reregisterSessionNotifications() error {
if m.hwnd == 0 {
return fmt.Errorf("no window handle available")
}
// Unregister first
wtsUnRegisterSessionNotification(m.hwnd)
// Small delay before re-registering
time.Sleep(100 * time.Millisecond)
// Re-register
err := wtsRegisterSessionNotification(m.hwnd, NOTIFY_FOR_ALL_SESSIONS)
if err != nil {
m.logger.Error("Failed to re-register session notifications: %v", err)
return err
}
m.logger.Info("Successfully re-registered session notifications")
return nil
}
// healthMonitor monitors session notification health and re-registers if needed
func (m *Monitor) healthMonitor() {
for {
select {
case <-m.stopChan:
if m.healthCheckTicker != nil {
m.healthCheckTicker.Stop()
}
return
case <-m.healthCheckTicker.C:
// Check if we've received any notifications in the last 10 minutes
// Note: This assumes some activity on the system, adjust threshold as needed
timeSinceLastNotification := time.Since(m.lastNotificationTime)
// If no notifications for more than 15 minutes and system isn't locked,
// consider re-registering (aggressive approach for testing)
if timeSinceLastNotification > 15*time.Minute {
m.logger.Warning("No session notifications received for %v, attempting re-registration", timeSinceLastNotification)
if err := m.reregisterSessionNotifications(); err != nil {
m.logger.Error("Failed to re-register session notifications: %v", err)
m.notificationHealthy = false
} else {
m.notificationHealthy = true
// Reset the timer to give the re-registration a chance
m.lastNotificationTime = time.Now()
}
}
}
}
}