diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a38b7a2 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module git.send.nrw/sendnrw/siem-agent + +go 1.26.1 + +require golang.org/x/sys v0.43.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..71016e3 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/main.go b/main.go new file mode 100644 index 0000000..facee3d --- /dev/null +++ b/main.go @@ -0,0 +1,954 @@ +//go:build windows + +package main + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/base64" + "encoding/json" + "encoding/xml" + "errors" + "fmt" + "io" + "log" + "net/http" + "os" + "os/signal" + "path/filepath" + "sort" + "strings" + "sync" + "syscall" + "time" + "unsafe" + + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/svc" + "golang.org/x/sys/windows/svc/debug" + "golang.org/x/sys/windows/svc/eventlog" + "golang.org/x/sys/windows/svc/mgr" +) + +const ( + ServiceName = "WinEventForwarder" + + BatchSize = 8 + PollWaitMS = 2000 + FlushInterval = 5 * time.Second + HTTPTimeout = 10 * time.Second + ServiceLogInfo = 1 +) + +const AgentConfigPath = `C:\ProgramData\WinEventForwarder\agent.json` + +type ChannelConfig struct { + Name string + IDs map[uint32]bool +} + +var channelConfigs = []ChannelConfig{ + { + Name: "System", + IDs: map[uint32]bool{ + 1074: true, // Shutdown/Reboot + 6005: true, // Eventlog gestartet + 6006: true, // Eventlog gestoppt + }, + }, + { + Name: "Application", + IDs: map[uint32]bool{ + 1000: true, // Beispiel: Application Error + }, + }, + // Beispiel: + // { + // Name: "Security", + // IDs: map[uint32]bool{ + // 4624: true, + // 4625: true, + // }, + // }, +} + +type AgentConfig struct { + BackendURL string `json:"backend_url"` + EnrollmentKey string `json:"enrollment_key"` + ChannelRules []ChannelRule `json:"channel_rules"` + StateFile string `json:"state_file"` +} + +type ChannelRule struct { + Name string `json:"name"` + IDs []uint32 `json:"ids"` +} + +type AgentState struct { + Hostname string `json:"hostname"` + APIKey string `json:"api_key"` + Enrolled bool `json:"enrolled"` + EnrolledAt time.Time `json:"enrolled_at,omitempty"` +} + +type LogPayload struct { + Hostname string `json:"host"` + Channel string `json:"channel"` + EventID uint32 `json:"id"` + Source string `json:"source"` + Time time.Time `json:"ts"` + Message string `json:"msg"` +} + +type eventXML struct { + System struct { + Provider struct { + Name string `xml:"Name,attr"` + } `xml:"Provider"` + EventID uint32 `xml:"EventID"` + Computer string `xml:"Computer"` + TimeCreated struct { + SystemTime string `xml:"SystemTime,attr"` + } `xml:"TimeCreated"` + } `xml:"System"` +} + +var ( + modWevtapi = windows.NewLazySystemDLL("wevtapi.dll") + procEvtSubscribe = modWevtapi.NewProc("EvtSubscribe") + procEvtNext = modWevtapi.NewProc("EvtNext") + procEvtRender = modWevtapi.NewProc("EvtRender") + procEvtClose = modWevtapi.NewProc("EvtClose") +) + +const ( + evtSubscribeToFutureEvents = 1 + evtRenderEventXML = 1 +) + +type myservice struct { + cfg *AgentConfig + state *AgentState +} + +func (m *myservice) Execute(args []string, r <-chan svc.ChangeRequest, status chan<- svc.Status) (bool, uint32) { + const cmdsAccepted = svc.AcceptStop | svc.AcceptShutdown + status <- svc.Status{State: svc.StartPending} + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + hostname, _ := os.Hostname() + client := &http.Client{Timeout: HTTPTimeout} + out := make(chan LogPayload, 256) + + sendInitialHello(client, m.cfg, m.state, hostname) + + var wg sync.WaitGroup + + // Sender + wg.Add(1) + go func() { + defer wg.Done() + runSender( + ctx, + client, + m.cfg.BackendURL, + m.cfg.EnrollmentKey, + m.cfg.StateFile, + m.state, + out, + ) + }() + + // Channel-Worker + runtimeChannels := buildChannelConfigs(m.cfg.ChannelRules) + + for _, chCfg := range runtimeChannels { + chCfg := chCfg + wg.Add(1) + go func() { + defer wg.Done() + runChannelWatcher(ctx, hostname, chCfg, out) + }() + } + + status <- svc.Status{State: svc.Running, Accepts: cmdsAccepted} + +loop: + for c := range r { // "for range" is what Staticcheck prefers + switch c.Cmd { + case svc.Interrogate: + status <- c.CurrentStatus + case svc.Stop, svc.Shutdown: + break loop + } + } + + cancel() + wg.Wait() + status <- svc.Status{State: svc.Stopped} + return false, 0 +} + +func main() { + + if err := setupFileLogging(); err != nil { + fmt.Printf("Logging-Setup fehlgeschlagen: %v\n", err) + } + + cfg, state, hostname, err := initAgent(AgentConfigPath) + if err != nil { + log.Fatalf("Agent-Initialisierung fehlgeschlagen: %v", err) + } + + fmt.Printf("Agent gestartet auf %s\n", hostname) + fmt.Printf("Backend: %s\n", cfg.BackendURL) + fmt.Printf("Enrolled: %v\n", state.Enrolled) + + isService, err := svc.IsWindowsService() + if err != nil { + log.Fatalf("svc.IsWindowsService: %v", err) + } + + if len(os.Args) > 1 { + switch strings.ToLower(os.Args[1]) { + case "install": + if err := installService(); err != nil { + log.Fatalf("Install fehlgeschlagen: %v", err) + } + fmt.Println("Dienst installiert.") + return + case "remove", "uninstall": + if err := removeService(); err != nil { + log.Fatalf("Entfernen fehlgeschlagen: %v", err) + } + fmt.Println("Dienst entfernt.") + return + case "debug": + runDebug(cfg, state) + return + } + } + + if !isService { + fmt.Println("Interaktive Sitzung erkannt. Starte im Debug-Modus.") + runDebug(cfg, state) + return + } + + if err := svc.Run(ServiceName, &myservice{ + cfg: cfg, + state: state, + }); err != nil { + log.Fatalf("svc.Run: %v", err) + } +} + +func runDebug(cfg *AgentConfig, state *AgentState) { + elog := debug.New(ServiceName) + defer elog.Close() + + hostname, _ := os.Hostname() + client := &http.Client{Timeout: HTTPTimeout} + out := make(chan LogPayload, 256) + + sendInitialHello(client, cfg, state, hostname) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + var wg sync.WaitGroup + + wg.Add(1) + go func() { + defer wg.Done() + runSender( + ctx, + client, + cfg.BackendURL, + cfg.EnrollmentKey, + cfg.StateFile, + state, + out, + ) + }() + + runtimeChannels := buildChannelConfigs(cfg.ChannelRules) + + for _, chCfg := range runtimeChannels { + chCfg := chCfg + wg.Add(1) + go func() { + defer wg.Done() + runChannelWatcher(ctx, hostname, chCfg, out) + }() + } + + elog.Info(ServiceLogInfo, "Debug-Modus läuft. Mit Strg+C beenden.") + + // Create a channel to listen for Windows signals (like Ctrl+C) + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + + select { + case <-sigChan: + elog.Info(ServiceLogInfo, "Beenden-Signal empfangen...") + case <-ctx.Done(): + } + + cancel() // Trigger shutdown for workers + wg.Wait() // Wait for workers to clean up +} + +func runChannelWatcher(ctx context.Context, hostname string, cfg ChannelConfig, out chan<- LogPayload) { + query := buildXPathQuery(cfg.IDs) + + signal, err := windows.CreateEvent(nil, 1, 0, nil) + if err != nil { + log.Printf("[%s] CreateEvent-Fehler: %v", cfg.Name, err) + return + } + defer windows.CloseHandle(signal) + + sub, err := evtSubscribe(cfg.Name, query, signal, evtSubscribeToFutureEvents) + if err != nil { + log.Printf("[%s] Subscribe-Fehler: %v | Query=%s", cfg.Name, err, query) + return + } + defer evtClose(sub) + + log.Printf("[%s] Überwachung gestartet mit Filter %s", cfg.Name, query) + + for { + select { + case <-ctx.Done(): + return + default: + } + + waitStatus, err := windows.WaitForSingleObject(signal, PollWaitMS) + if err != nil { + log.Printf("[%s] WaitForSingleObject-Fehler: %v", cfg.Name, err) + time.Sleep(2 * time.Second) + continue + } + + switch waitStatus { + case uint32(windows.WAIT_OBJECT_0): + events, err := evtNext(sub, BatchSize, 0) + if err != nil { + if isIgnorableEvtNextError(err) { + _ = windows.ResetEvent(signal) + continue + } + log.Printf("[%s] EvtNext-Fehler: %v", cfg.Name, err) + _ = windows.ResetEvent(signal) + time.Sleep(2 * time.Second) + continue + } + + for _, h := range events { + payload, err := buildPayloadFromEventHandle(hostname, cfg.Name, h) + _ = evtClose(h) + + if err != nil { + log.Printf("[%s] Event-Verarbeitung fehlgeschlagen: %v", cfg.Name, err) + continue + } + + if !cfg.IDs[payload.EventID] { + continue + } + + select { + case out <- payload: + case <-ctx.Done(): + return + } + } + + _ = windows.ResetEvent(signal) + + case uint32(windows.WAIT_TIMEOUT): + continue + + default: + log.Printf("[%s] Unerwarteter Wait-Status: %d", cfg.Name, waitStatus) + time.Sleep(2 * time.Second) + } + } +} + +func runSender( + ctx context.Context, + client *http.Client, + backendURL string, + enrollmentKey string, + stateFile string, + state *AgentState, + in <-chan LogPayload, +) { + ticker := time.NewTicker(FlushInterval) + defer ticker.Stop() + + var batch []LogPayload + + flush := func() { + if len(batch) == 0 { + return + } + + ok, err := sendBatch(client, backendURL, state, enrollmentKey, batch) + if err != nil { + log.Printf("sendBatch Fehler: %v", err) + return + } + + if ok { + if !state.Enrolled { + state.Enrolled = true + state.EnrolledAt = time.Now().UTC() + + if err := saveAgentState(stateFile, state); err != nil { + log.Printf("state speichern fehlgeschlagen: %v", err) + } else { + log.Printf("Agent erfolgreich enrolled") + } + } + + log.Printf("%d Events erfolgreich gesendet.", len(batch)) + batch = nil + } + } + + for { + select { + case <-ctx.Done(): + flush() + return + + case item, ok := <-in: + if !ok { + flush() + return + } + batch = append(batch, item) + if len(batch) >= 25 { + flush() + } + + case <-ticker.C: + flush() + } + } +} + +func buildPayloadFromEventHandle(hostname, channel string, h windows.Handle) (LogPayload, error) { + xmlStr, err := evtRenderXML(h) + if err != nil { + return LogPayload{}, fmt.Errorf("render xml: %w", err) + } + + id, source, ts, err := extractEventMeta(xmlStr) + if err != nil { + return LogPayload{}, fmt.Errorf("parse xml: %w", err) + } + + return LogPayload{ + Hostname: hostname, + Channel: channel, + EventID: id, + Source: source, + Time: ts, + Message: xmlStr, + }, nil +} + +func extractEventMeta(xmlStr string) (uint32, string, time.Time, error) { + var evt eventXML + if err := xml.Unmarshal([]byte(xmlStr), &evt); err != nil { + return 0, "", time.Time{}, err + } + + source := evt.System.Provider.Name + if source == "" { + source = "unknown" + } + + ts := time.Now() + if evt.System.TimeCreated.SystemTime != "" { + if parsed, err := parseWindowsSystemTime(evt.System.TimeCreated.SystemTime); err == nil { + ts = parsed + } + } + + return evt.System.EventID, source, ts, nil +} + +func parseWindowsSystemTime(v string) (time.Time, error) { + layouts := []string{ + time.RFC3339Nano, + "2006-01-02T15:04:05.9999999Z07:00", + "2006-01-02T15:04:05.999999Z07:00", + "2006-01-02T15:04:05Z07:00", + } + for _, layout := range layouts { + if t, err := time.Parse(layout, v); err == nil { + return t, nil + } + } + return time.Time{}, fmt.Errorf("unbekanntes Zeitformat: %s", v) +} + +func buildXPathQuery(ids map[uint32]bool) string { + if len(ids) == 0 { + return "*" + } + + list := make([]int, 0, len(ids)) + for id := range ids { + list = append(list, int(id)) + } + sort.Ints(list) + + parts := make([]string, 0, len(list)) + for _, id := range list { + // Correct syntax: EventID=1074 + parts = append(parts, fmt.Sprintf("EventID=%d", id)) + } + + // Ensure the structure is exactly: *[System[(EventID=1074 or EventID=6005)]] + return fmt.Sprintf("*[System[(%s)]]", strings.Join(parts, " or ")) +} + +func sendBatch(client *http.Client, backendURL string, state *AgentState, enrollmentKey string, batch []LogPayload) (bool, error) { + data, err := json.Marshal(batch) + if err != nil { + return false, err + } + + req, err := http.NewRequest(http.MethodPost, backendURL, bytes.NewBuffer(data)) + if err != nil { + return false, err + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-API-Key", state.APIKey) + + if !state.Enrolled { + req.Header.Set("X-Enrollment-Key", enrollmentKey) + } + + resp, err := client.Do(req) + if err != nil { + return false, err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusAccepted { + return true, nil + } + + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return false, fmt.Errorf("backend antwortete mit HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) +} + +func evtSubscribe(channelPath, query string, signal windows.Handle, flags uint32) (windows.Handle, error) { + chPtr, err := windows.UTF16PtrFromString(channelPath) + if err != nil { + return 0, err + } + qPtr, err := windows.UTF16PtrFromString(query) + if err != nil { + return 0, err + } + + r1, _, e1 := procEvtSubscribe.Call( + 0, // Session = local + uintptr(signal), + uintptr(unsafe.Pointer(chPtr)), + uintptr(unsafe.Pointer(qPtr)), + 0, // Bookmark + 0, // Context + 0, // Callback = NULL, weil Pull-Modell + uintptr(flags), + ) + if r1 == 0 { + if e1 != syscall.Errno(0) { + return 0, e1 + } + return 0, errors.New("EvtSubscribe fehlgeschlagen") + } + + return windows.Handle(r1), nil +} + +func evtNext(resultSet windows.Handle, maxHandles uint32, timeout uint32) ([]windows.Handle, error) { + handles := make([]windows.Handle, maxHandles) + var returned uint32 + + r1, _, e1 := procEvtNext.Call( + uintptr(resultSet), + uintptr(maxHandles), + uintptr(unsafe.Pointer(&handles[0])), + uintptr(timeout), + 0, + uintptr(unsafe.Pointer(&returned)), + ) + if r1 == 0 { + if e1 != syscall.Errno(0) { + return nil, e1 + } + return nil, errors.New("EvtNext fehlgeschlagen") + } + + if returned == 0 { + return nil, nil + } + + return handles[:returned], nil +} + +func evtRenderXML(eventHandle windows.Handle) (string, error) { + var bufferUsed uint32 + var propertyCount uint32 + + r1, _, e1 := procEvtRender.Call( + 0, + uintptr(eventHandle), + uintptr(evtRenderEventXML), + 0, + 0, + uintptr(unsafe.Pointer(&bufferUsed)), + uintptr(unsafe.Pointer(&propertyCount)), + ) + + if r1 == 0 { + if errno, ok := e1.(syscall.Errno); ok { + if errno != windows.ERROR_INSUFFICIENT_BUFFER { + return "", errno + } + } else if e1 != syscall.Errno(0) { + return "", e1 + } + } + + if bufferUsed == 0 { + return "", errors.New("EvtRender lieferte keine Buffergröße") + } + + buf := make([]uint16, bufferUsed/2) + + r1, _, e1 = procEvtRender.Call( + 0, + uintptr(eventHandle), + uintptr(evtRenderEventXML), + uintptr(bufferUsed), + uintptr(unsafe.Pointer(&buf[0])), + uintptr(unsafe.Pointer(&bufferUsed)), + uintptr(unsafe.Pointer(&propertyCount)), + ) + if r1 == 0 { + if e1 != syscall.Errno(0) { + return "", e1 + } + return "", errors.New("EvtRender fehlgeschlagen") + } + + return windows.UTF16ToString(buf), nil +} + +func evtClose(h windows.Handle) error { + r1, _, e1 := procEvtClose.Call(uintptr(h)) + if r1 == 0 { + if e1 != syscall.Errno(0) { + return e1 + } + return errors.New("EvtClose fehlgeschlagen") + } + return nil +} + +func isIgnorableEvtNextError(err error) bool { + var errno syscall.Errno + if errors.As(err, &errno) { + if errno == windows.ERROR_TIMEOUT || errno == windows.ERROR_NO_MORE_ITEMS { + return true + } + } + return false +} + +func installService() error { + exepath, err := os.Executable() + if err != nil { + return err + } + + m, err := mgr.Connect() + if err != nil { + return err + } + defer m.Disconnect() + + s, err := m.OpenService(ServiceName) + if err == nil { + s.Close() + return fmt.Errorf("Dienst %q existiert bereits", ServiceName) + } + + s, err = m.CreateService(ServiceName, exepath, mgr.Config{ + DisplayName: ServiceName, + StartType: mgr.StartAutomatic, + Description: "Forwardet Windows Eventlogs an ein HTTP-Backend", + }) + if err != nil { + return err + } + defer s.Close() + + err = eventlog.InstallAsEventCreate(ServiceName, eventlog.Info|eventlog.Warning|eventlog.Error) + if err != nil { + _ = s.Delete() + return err + } + + return nil +} + +func removeService() error { + m, err := mgr.Connect() + if err != nil { + return err + } + defer m.Disconnect() + + s, err := m.OpenService(ServiceName) + if err != nil { + return err + } + defer s.Close() + + if err := s.Delete(); err != nil { + return err + } + + _ = eventlog.Remove(ServiceName) + return nil +} + +func loadAgentState(path string) (*AgentState, error) { + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return &AgentState{}, nil + } + return nil, err + } + + var st AgentState + if err := json.Unmarshal(data, &st); err != nil { + return nil, err + } + return &st, nil +} + +func saveAgentState(path string, st *AgentState) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + + data, err := json.MarshalIndent(st, "", " ") + if err != nil { + return err + } + + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0o600); err != nil { + return err + } + + return os.Rename(tmp, path) +} + +func generateAgentKey() (string, error) { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(buf), nil +} + +func loadAgentConfig(path string) (*AgentConfig, error) { + cfg, err := ensureAgentConfig(path) + if err != nil { + return nil, err + } + + if strings.TrimSpace(cfg.BackendURL) == "" { + return nil, errors.New("backend_url fehlt") + } + if strings.TrimSpace(cfg.StateFile) == "" { + return nil, errors.New("state_file fehlt") + } + + return cfg, nil +} + +func initAgent(configPath string) (*AgentConfig, *AgentState, string, error) { + cfg, err := loadAgentConfig(configPath) + if err != nil { + return nil, nil, "", err + } + + hostname, err := os.Hostname() + if err != nil { + return nil, nil, "", err + } + + state, err := loadAgentState(cfg.StateFile) + if err != nil { + return nil, nil, "", err + } + + if strings.TrimSpace(state.Hostname) == "" { + state.Hostname = hostname + } + + if strings.TrimSpace(state.APIKey) == "" { + key, err := generateAgentKey() + if err != nil { + return nil, nil, "", err + } + state.APIKey = key + } + + if err := saveAgentState(cfg.StateFile, state); err != nil { + return nil, nil, "", err + } + + return cfg, state, hostname, nil +} + +func defaultAgentConfig() *AgentConfig { + return &AgentConfig{ + BackendURL: getenvDefault("SIEM_BACKEND_URL", "http://10.10.5.220:8090/ingest"), + EnrollmentKey: getenvDefault("SIEM_ENROLLMENT_KEY", "BITTE_SEHR_LANG_UND_ZUFAELLIG"), + StateFile: `C:\ProgramData\WinEventForwarder\state.json`, + ChannelRules: []ChannelRule{ + {Name: "System", IDs: []uint32{1074, 6005, 6006}}, + {Name: "Security", IDs: []uint32{4624, 4625}}, + }, + } +} + +func ensureAgentConfig(path string) (*AgentConfig, error) { + data, err := os.ReadFile(path) + if err == nil { + var cfg AgentConfig + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("config ungültig: %w", err) + } + return &cfg, nil + } + + if !errors.Is(err, os.ErrNotExist) { + return nil, err + } + + cfg := defaultAgentConfig() + + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return nil, err + } + + raw, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return nil, err + } + + if err := os.WriteFile(path, raw, 0o600); err != nil { + return nil, err + } + + return cfg, nil +} + +func getenvDefault(key, def string) string { + v := strings.TrimSpace(os.Getenv(key)) + if v == "" { + return def + } + return v +} + +func buildChannelConfigs(rules []ChannelRule) []ChannelConfig { + out := make([]ChannelConfig, 0, len(rules)) + for _, r := range rules { + ids := make(map[uint32]bool, len(r.IDs)) + for _, id := range r.IDs { + ids[id] = true + } + out = append(out, ChannelConfig{ + Name: r.Name, + IDs: ids, + }) + } + return out +} + +func sendInitialHello(client *http.Client, cfg *AgentConfig, state *AgentState, hostname string) { + batch := []LogPayload{ + { + Hostname: hostname, + Channel: "Agent", + EventID: 1, + Source: "WinEventForwarder", + Time: time.Now().UTC(), + Message: "Agent startup / enrollment hello", + }, + } + + ok, err := sendBatch(client, cfg.BackendURL, state, cfg.EnrollmentKey, batch) + if err != nil { + log.Printf("Initial hello fehlgeschlagen: %v", err) + return + } + + if ok { + if !state.Enrolled { + state.Enrolled = true + state.EnrolledAt = time.Now().UTC() + + if err := saveAgentState(cfg.StateFile, state); err != nil { + log.Printf("state speichern nach Initial-Hello fehlgeschlagen: %v", err) + } else { + log.Printf("Agent erfolgreich per Initial-Hello enrolled") + } + } + + log.Printf("Initial hello erfolgreich gesendet") + } +} + +func setupFileLogging() error { + logDir := `C:\ProgramData\WinEventForwarder` + if err := os.MkdirAll(logDir, 0o755); err != nil { + return err + } + + f, err := os.OpenFile( + filepath.Join(logDir, "agent.log"), + os.O_CREATE|os.O_APPEND|os.O_WRONLY, + 0o644, + ) + if err != nil { + return err + } + + log.SetOutput(f) + log.SetFlags(log.LstdFlags | log.Lmicroseconds | log.LUTC) + return nil +} diff --git a/start b/start new file mode 100644 index 0000000..dfb74cd --- /dev/null +++ b/start @@ -0,0 +1 @@ +WinEventForwarder diff --git a/stop b/stop new file mode 100644 index 0000000..dfb74cd --- /dev/null +++ b/stop @@ -0,0 +1 @@ +WinEventForwarder