Anpassung auf Windows-Dienst
All checks were successful
build-binaries / build (.exe, amd64, windows) (push) Successful in 10m15s
build-binaries / release (push) Successful in 13s
build-binaries / publish-agent (push) Successful in 9s

This commit is contained in:
2025-10-30 14:04:30 +01:00
parent 4865db2bd5
commit 25e5a6adc1
3 changed files with 277 additions and 11 deletions

View File

@@ -16,7 +16,9 @@
## Features ## Features
- Funktion 1 - sc.exe create GoSysInfoService binPath="E:\GoProjects\pcinfo\pcinfo.exe" start=auto
- sc.exe description GoSysInfoService "Hilden PC-Info"
- sc.exe start GoSysInfoService
--- ---

284
main.go
View File

@@ -1,6 +1,9 @@
//go:build windows
package main package main
import ( import (
"context"
"encoding/json" "encoding/json"
"html/template" "html/template"
"log" "log"
@@ -9,6 +12,7 @@ import (
"os" "os"
"os/user" "os/user"
"runtime" "runtime"
"sort"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -17,6 +21,9 @@ import (
"github.com/shirou/gopsutil/v3/disk" "github.com/shirou/gopsutil/v3/disk"
"github.com/shirou/gopsutil/v3/host" "github.com/shirou/gopsutil/v3/host"
"github.com/shirou/gopsutil/v3/mem" "github.com/shirou/gopsutil/v3/mem"
"golang.org/x/sys/windows/registry"
"golang.org/x/sys/windows/svc"
) )
type NetInterface struct { type NetInterface struct {
@@ -64,17 +71,126 @@ type Summary struct {
Disks []DiskInfo `json:"disks"` Disks []DiskInfo `json:"disks"`
} }
type InstalledApp struct {
Name string `json:"name"`
Version string `json:"version"`
Publisher string `json:"publisher"`
InstallDate string `json:"install_date"`
Source string `json:"source"` // HKLM-64, HKLM-32, HKCU
}
var ( var (
mu sync.RWMutex mu sync.RWMutex
lastCPUUsage float64 lastCPUUsage float64
) )
func readUninstallKey(base registry.Key, path, src string) []InstalledApp {
k, err := registry.OpenKey(base, path, registry.ENUMERATE_SUB_KEYS|registry.QUERY_VALUE)
if err != nil {
return nil
}
defer k.Close()
names, err := k.ReadSubKeyNames(-1)
if err != nil {
return nil
}
apps := make([]InstalledApp, 0, len(names))
for _, name := range names {
sk, err := registry.OpenKey(k, name, registry.QUERY_VALUE)
if err != nil {
continue
}
// versteckte System-Komponenten ausblenden
if v, _, err := sk.GetIntegerValue("SystemComponent"); err == nil && v == 1 {
sk.Close()
continue
}
displayName, _, _ := sk.GetStringValue("DisplayName")
if strings.TrimSpace(displayName) == "" {
sk.Close()
continue
}
displayVersion, _, _ := sk.GetStringValue("DisplayVersion")
publisher, _, _ := sk.GetStringValue("Publisher")
installDate, _, _ := sk.GetStringValue("InstallDate")
apps = append(apps, InstalledApp{
Name: displayName,
Version: displayVersion,
Publisher: publisher,
InstallDate: installDate,
Source: src,
})
sk.Close()
}
return apps
}
func getInstalledApps() []InstalledApp {
var all []InstalledApp
// Maschinenweit 64-bit
all = append(all,
readUninstallKey(registry.LOCAL_MACHINE,
`SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall`, "HKLM-64")...,
)
// Maschinenweit 32-bit auf 64-bit
all = append(all,
readUninstallKey(registry.LOCAL_MACHINE,
`SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall`, "HKLM-32")...,
)
// Aktueller Benutzer
all = append(all,
readUninstallKey(registry.CURRENT_USER,
`SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall`, "HKCU")...,
)
// Deduplizieren (Name+Version+Publisher)
uniq := make(map[string]InstalledApp, len(all))
for _, a := range all {
key := strings.ToLower(strings.TrimSpace(a.Name) + "|" + strings.TrimSpace(a.Version) + "|" + strings.TrimSpace(a.Publisher))
if _, ok := uniq[key]; !ok {
uniq[key] = a
}
}
out := make([]InstalledApp, 0, len(uniq))
for _, v := range uniq {
out = append(out, v)
}
sort.Slice(out, func(i, j int) bool { return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name) })
return out
}
func appsHandler(w http.ResponseWriter, r *http.Request) {
apps := getInstalledApps()
w.Header().Set("Content-Type", "application/json")
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
_ = enc.Encode(apps)
}
// background sampler for CPU percentage to avoid blocking API calls // background sampler for CPU percentage to avoid blocking API calls
func startCPUSampler() { func startCPUSampler(ctx context.Context) {
go func() { go func() {
// Prime once // Prime once
_, _ = cpu.Percent(0, false) _, _ = cpu.Percent(0, false)
for { for {
select {
case <-ctx.Done():
return
default:
}
pcts, err := cpu.Percent(time.Second, false) pcts, err := cpu.Percent(time.Second, false)
if err == nil && len(pcts) > 0 { if err == nil && len(pcts) > 0 {
mu.Lock() mu.Lock()
@@ -92,7 +208,6 @@ func getInterfaces() []NetInterface {
} }
var out []NetInterface var out []NetInterface
for _, ifc := range ifaces { for _, ifc := range ifaces {
// Skip interfaces that are down
if (ifc.Flags & net.FlagUp) == 0 { if (ifc.Flags & net.FlagUp) == 0 {
continue continue
} }
@@ -115,7 +230,6 @@ func getInterfaces() []NetInterface {
} }
ips = append(ips, ip.String()) ips = append(ips, ip.String())
} }
// keep even if no IPs, to show MAC etc., but skip pure loopback
if isLoop { if isLoop {
continue continue
} }
@@ -141,7 +255,6 @@ func getDisks() []DiskInfo {
continue continue
} }
// read-only erkennen (gopsutil: Opts kann string ODER []string sein)
ro := false ro := false
switch opts := any(p.Opts).(type) { switch opts := any(p.Opts).(type) {
case string: case string:
@@ -200,7 +313,7 @@ func collectSummary() Summary {
Timestamp: time.Now(), Timestamp: time.Now(),
Hostname: safe(hi, func(h *host.InfoStat) string { return h.Hostname }), Hostname: safe(hi, func(h *host.InfoStat) string { return h.Hostname }),
Username: username, Username: username,
OS: safe(hi, func(h *host.InfoStat) string { return h.OS }), // "windows" OS: safe(hi, func(h *host.InfoStat) string { return h.OS }),
Platform: safe(hi, func(h *host.InfoStat) string { return h.Platform }), Platform: safe(hi, func(h *host.InfoStat) string { return h.Platform }),
PlatformVersion: safe(hi, func(h *host.InfoStat) string { return h.PlatformVersion }), PlatformVersion: safe(hi, func(h *host.InfoStat) string { return h.PlatformVersion }),
KernelVersion: safe(hi, func(h *host.InfoStat) string { return h.KernelVersion }), KernelVersion: safe(hi, func(h *host.InfoStat) string { return h.KernelVersion }),
@@ -239,7 +352,7 @@ var page = template.Must(template.New("index").Parse(`<!doctype html>
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>Windows Systeminfo</title> <title>Windows Systeminfo</title>
<style> <style>
:root { --bg:#0b1220; --fg:#e7eefc; --muted:#a3b1cc; --card:#111a2e; --accent:#5aa9ff; } :root { --bg:#000000; --fg:#e7eefc; --muted:#a3b1cc; --card:#111a2e; --accent:#5aa9ff; }
*{box-sizing:border-box} *{box-sizing:border-box}
body{max-width:980px;margin:0 auto;padding:24px;font-family:system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Cantarell,Noto Sans,sans-serif;background:var(--bg);color:var(--fg)} body{max-width:980px;margin:0 auto;padding:24px;font-family:system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Cantarell,Noto Sans,sans-serif;background:var(--bg);color:var(--fg)}
h1{font-size:22px;margin:0 0 16px 0} h1{font-size:22px;margin:0 0 16px 0}
@@ -306,6 +419,28 @@ var page = template.Must(template.New("index").Parse(`<!doctype html>
<tbody id="disks"></tbody> <tbody id="disks"></tbody>
</table> </table>
</div> </div>
<div class="card">
<div class="k">Installierte Programme</div>
<div class="row" style="gap:8px;align-items:center;">
<button id="btnLoadApps" class="pill" style="cursor:pointer;">Laden</button>
<input id="appsFilter" type="text" placeholder="Suchen …"
style="flex:1;padding:6px 10px;border-radius:8px;border:1px solid #1b2a4a;background:#0b1220;color:var(--fg)">
<span class="k" id="appsCount"></span>
</div>
<div class="apps">
<table style="width:100%;border-collapse:collapse">
<thead>
<tr>
<th style="text-align:left;padding:8px;border-bottom:1px solid #1b2a4a">Name</th>
<th style="text-align:left;padding:8px;border-bottom:1px solid #1b2a4a">Version</th>
<th style="text-align:left;padding:8px;border-bottom:1px solid #1b2a4a">Publisher</th>
<th style="text-align:left;padding:8px;border-bottom:1px solid #1b2a4a">Quelle</th>
</tr>
</thead>
<tbody id="apps"></tbody>
</table>
</div>
</div>
</div> </div>
<footer>Aktualisiert alle 2s</footer> <footer>Aktualisiert alle 2s</footer>
@@ -362,6 +497,55 @@ async function load(){
load(); load();
setInterval(load, 2000); setInterval(load, 2000);
let appsCache = null;
function renderApps(list){
const tbody = document.getElementById('apps');
tbody.innerHTML = '';
(list||[]).forEach(a=>{
const tr = document.createElement('tr');
tr.innerHTML =
'<td class="mono" style="padding:6px 8px;border-bottom:1px solid #1b2a4a">'+(a.name||'-')+'</td>'+
'<td style="padding:6px 8px;border-bottom:1px solid #1b2a4a">'+(a.version||'')+'</td>'+
'<td style="padding:6px 8px;border-bottom:1px solid #1b2a4a">'+(a.publisher||'')+'</td>'+
'<td style="padding:6px 8px;border-bottom:1px solid #1b2a4a">'+(a.source||'')+'</td>';
tbody.appendChild(tr);
});
const c = document.getElementById('appsCount');
if(c) c.textContent = (list||[]).length+' Einträge';
}
async function loadAppsOnce(){
if(appsCache) return; // schon geladen
try{
const r = await fetch('/api/apps');
appsCache = await r.json();
renderApps(appsCache);
}catch(e){
console.error(e);
}
}
document.addEventListener('DOMContentLoaded', ()=>{
const btn = document.getElementById('btnLoadApps');
const inp = document.getElementById('appsFilter');
if(btn){ btn.addEventListener('click', loadAppsOnce); }
if(inp){
inp.addEventListener('input', ()=>{
if(!appsCache) return;
const q = inp.value.toLowerCase();
const filtered = appsCache.filter(a =>
(a.name||'').toLowerCase().includes(q) ||
(a.version||'').toLowerCase().includes(q) ||
(a.publisher||'').toLowerCase().includes(q) ||
(a.source||'').toLowerCase().includes(q)
);
renderApps(filtered);
});
}
});
</script> </script>
</body> </body>
</html>`)) </html>`))
@@ -383,16 +567,96 @@ func apiHandler(w http.ResponseWriter, r *http.Request) {
} }
} }
func main() { // --- ab hier: Dienst-Integration ---
startCPUSampler()
const serviceName = "GoSysInfoService"
// runHTTP startet deinen bisherigen HTTP-Server und reagiert auf ctx.Done()
func runHTTP(ctx context.Context) error {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/", indexHandler) mux.HandleFunc("/", indexHandler)
mux.HandleFunc("/api/apps", appsHandler)
mux.HandleFunc("/api/summary", apiHandler) mux.HandleFunc("/api/summary", apiHandler)
addr := "127.0.0.1:8080" // bind strictly to loopback (IPv4) addr := "127.0.0.1:8080"
srv := &http.Server{
Addr: addr,
Handler: mux,
}
// CPU-Sampler an das gleiche ctx hängen
startCPUSampler(ctx)
log.Printf("Starte lokales Dashboard auf http://%s … (nur von diesem Rechner erreichbar)", addr) log.Printf("Starte lokales Dashboard auf http://%s … (nur von diesem Rechner erreichbar)", addr)
if err := http.ListenAndServe(addr, mux); err != nil {
// Shutdown-Goroutine
go func() {
<-ctx.Done()
shCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
_ = srv.Shutdown(shCtx)
cancel() // explizit aufrufen, kein defer in der Goroutine nötig
}()
// Blockiert hier bis Stop oder Fehler
err := srv.ListenAndServe()
// ListenAndServe gibt bei Shutdown typischerweise http.ErrServerClosed zurück
if err == http.ErrServerClosed {
return nil
}
return err
}
// windows-svc Wrapper
type winService struct{}
func (s *winService) Execute(args []string, r <-chan svc.ChangeRequest, status chan<- svc.Status) (bool, uint32) {
const accepted = svc.AcceptStop | svc.AcceptShutdown
status <- svc.Status{State: svc.StartPending}
ctx, cancel := context.WithCancel(context.Background())
defer cancel() // <-- neu: für alle Rückgabepfade
go func() {
if err := runHTTP(ctx); err != nil {
log.Printf("HTTP server stopped: %v", err)
}
}()
status <- svc.Status{State: svc.Running, Accepts: accepted}
for c := range r {
switch c.Cmd {
case svc.Interrogate:
status <- c.CurrentStatus
case svc.Stop, svc.Shutdown:
status <- svc.Status{State: svc.StopPending}
return false, 0 // cancel() läuft trotzdem dank defer
}
}
// falls der Channel mal zu ist
return false, 0
}
func main() {
// Prüfen, ob wir als Windows-Dienst laufen
isService, err := svc.IsWindowsService()
if err != nil {
log.Fatalf("svc.IsWindowsService: %v", err)
}
if isService {
// Hier spricht das Programm mit dem Service Control Manager
if err := svc.Run(serviceName, &winService{}); err != nil {
log.Fatalf("svc.Run failed: %v", err)
}
return
}
// normaler Konsolenmodus
ctx := context.Background()
if err := runHTTP(ctx); err != nil {
log.Fatal(err) log.Fatal(err)
} }
} }

BIN
pcinfo.exe Normal file

Binary file not shown.