10 Commits
v1.0.1 ... main

Author SHA1 Message Date
39c3e1f695 gpupdate eingebaut
All checks were successful
build-binaries / build (.exe, amd64, windows) (push) Successful in 10m19s
build-binaries / release (push) Successful in 11s
build-binaries / publish-agent (push) Successful in 10s
2025-12-17 09:29:44 +01:00
99e55a2e87 update mit Notification
All checks were successful
build-binaries / build (.exe, amd64, windows) (push) Has been skipped
build-binaries / release (push) Has been skipped
build-binaries / publish-agent (push) Has been skipped
2025-12-16 21:17:36 +01:00
a7bb94ecab README.md aktualisiert
All checks were successful
build-binaries / build (.exe, amd64, windows) (push) Has been skipped
build-binaries / release (push) Has been skipped
build-binaries / publish-agent (push) Has been skipped
2025-12-11 09:36:49 +00:00
f4ed36121e Update für Device GUID
All checks were successful
build-binaries / build (.exe, amd64, windows) (push) Successful in 10m24s
build-binaries / release (push) Successful in 14s
build-binaries / publish-agent (push) Successful in 13s
2025-12-11 10:16:19 +01:00
de66b186b2 main.go aktualisiert
All checks were successful
build-binaries / build (.exe, amd64, windows) (push) Successful in 10m12s
build-binaries / release (push) Successful in 13s
build-binaries / publish-agent (push) Successful in 9s
2025-11-12 07:58:36 +00:00
59d8fb3e5d Anpassung auf Breite 1200
All checks were successful
build-binaries / build (.exe, amd64, windows) (push) Successful in 10m14s
build-binaries / release (push) Successful in 15s
build-binaries / publish-agent (push) Successful in 10s
2025-11-02 17:33:39 +01:00
a2cb8450e2 Template angepasst (Hell).
All checks were successful
build-binaries / build (.exe, amd64, windows) (push) Has been skipped
build-binaries / release (push) Has been skipped
build-binaries / publish-agent (push) Has been skipped
2025-11-02 17:22:13 +01:00
fee5b400a6 Anschluss geändert auf :24000
All checks were successful
build-binaries / build (.exe, amd64, windows) (push) Successful in 10m13s
build-binaries / release (push) Successful in 12s
build-binaries / publish-agent (push) Successful in 8s
2025-10-30 21:28:56 +01:00
8e4325a1f8 Erkennung der Netzwerk-Profile hinzugefügt 2025-10-30 21:21:04 +01:00
25e5a6adc1 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
2025-10-30 14:04:30 +01:00
3 changed files with 814 additions and 33 deletions

View File

@@ -16,7 +16,11 @@
## 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
- http://localhost:24000/api/devices?only_usb=1
---

841
main.go
View File

@@ -1,22 +1,32 @@
//go:build windows
package main
import (
"context"
"encoding/json"
"html/template"
"log"
"net"
"net/http"
"os"
"os/exec"
"os/user"
"runtime"
"sort"
"strconv"
"strings"
"sync"
"syscall"
"time"
"github.com/shirou/gopsutil/v3/cpu"
"github.com/shirou/gopsutil/v3/disk"
"github.com/shirou/gopsutil/v3/host"
"github.com/shirou/gopsutil/v3/mem"
"golang.org/x/sys/windows/registry"
"golang.org/x/sys/windows/svc"
)
type NetInterface struct {
@@ -24,6 +34,7 @@ type NetInterface struct {
MAC string `json:"mac"`
Addresses []string `json:"addresses"`
IsLoopback bool `json:"is_loopback"`
Profile string `json:"profile"`
}
type DiskInfo struct {
@@ -64,17 +75,376 @@ type Summary struct {
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
}
type DeviceInfo struct {
InstanceID string `json:"instance_id"`
Class string `json:"class"`
ClassGUID string `json:"class_guid"`
FriendlyName string `json:"friendly_name"`
Manufacturer string `json:"manufacturer"`
Status string `json:"status"`
Present bool `json:"present"`
}
type Notification struct {
ID int64 `json:"id"`
CreatedAt time.Time `json:"created_at"`
Title string `json:"title"`
Message string `json:"message"`
TargetUser string `json:"target_user"` // "" = Broadcast an alle User
}
var (
notifyMu sync.Mutex
notifySeq int64
notifyRing []Notification
)
// maximale Anzahl vorgehaltener Nachrichten (FIFO-Ring)
const maxNotifications = 1000
var (
mu sync.RWMutex
lastCPUUsage float64
)
func addNotification(n Notification) Notification {
notifyMu.Lock()
defer notifyMu.Unlock()
notifySeq++
n.ID = notifySeq
if n.CreatedAt.IsZero() {
n.CreatedAt = time.Now()
}
notifyRing = append(notifyRing, n)
if len(notifyRing) > maxNotifications {
// älteste abschneiden
notifyRing = notifyRing[len(notifyRing)-maxNotifications:]
}
return n
}
// userName: z.B. "DOMAIN\\user" oder "user"
// sinceID: letzte gesehene ID des Agents
func getNotificationsForUser(userName string, sinceID int64) []Notification {
notifyMu.Lock()
defer notifyMu.Unlock()
userNameLower := strings.ToLower(strings.TrimSpace(userName))
var res []Notification
for _, n := range notifyRing {
if n.ID <= sinceID {
continue
}
// Broadcast
if strings.TrimSpace(n.TargetUser) == "" {
res = append(res, n)
continue
}
// Ziel-User matchen (case-insensitive)
if strings.EqualFold(n.TargetUser, userName) ||
strings.EqualFold(n.TargetUser, userNameLower) {
res = append(res, n)
}
}
return res
}
type NotifyRequest struct {
Title string `json:"title"`
Message string `json:"message"`
TargetUser string `json:"target_user"` // optional, "" = an alle
}
func notifyFromServerHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// optional: Authentifizierung per Token
token := os.Getenv("NOTIFY_TOKEN")
if token != "" && r.Header.Get("X-Notify-Token") != token {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var req NotifyRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
return
}
req.Title = strings.TrimSpace(req.Title)
req.Message = strings.TrimSpace(req.Message)
req.TargetUser = strings.TrimSpace(req.TargetUser)
if req.Message == "" {
http.Error(w, "message must not be empty", http.StatusBadRequest)
return
}
if req.Title == "" {
req.Title = "Benachrichtigung"
}
n := addNotification(Notification{
Title: req.Title,
Message: req.Message,
TargetUser: req.TargetUser,
})
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(n)
}
func notificationsForAgentHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
userName := strings.TrimSpace(r.URL.Query().Get("user"))
if userName == "" {
http.Error(w, "missing 'user' query parameter", http.StatusBadRequest)
return
}
sinceStr := r.URL.Query().Get("since_id")
var sinceID int64
if sinceStr != "" {
if v, err := strconv.ParseInt(sinceStr, 10, 64); err == nil {
sinceID = v
}
}
// optional: Zugriff auf localhost beschränken
host, _, _ := net.SplitHostPort(r.RemoteAddr)
if host != "127.0.0.1" && host != "::1" {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
notifs := getNotificationsForUser(userName, sinceID)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(notifs)
}
func getPnpDevices() ([]DeviceInfo, error) {
// PowerShell: aktuelle PnP-Geräte, inkl. ClassGuid
cmd := exec.Command(
"powershell",
"-NoProfile",
"-NonInteractive",
"-Command",
`Get-PnpDevice -PresentOnly | Select-Object InstanceId,Class,ClassGuid,FriendlyName,Manufacturer,Status,Present | ConvertTo-Json -Depth 3`,
)
out, err := cmd.Output()
if err != nil {
log.Printf("getPnpDevices: powershell error: %v", err)
return nil, err
}
if len(out) == 0 {
return []DeviceInfo{}, nil
}
// PS gibt entweder ein Objekt oder ein Array zurück → wie bei getWinNetProfiles behandeln
type psDevice struct {
InstanceId string `json:"InstanceId"`
Class string `json:"Class"`
ClassGuid string `json:"ClassGuid"`
FriendlyName string `json:"FriendlyName"`
Manufacturer string `json:"Manufacturer"`
Status string `json:"Status"`
Present interface{} `json:"Present"`
}
parsePresent := func(v interface{}) bool {
switch t := v.(type) {
case bool:
return t
case string:
// "True"/"False" etc.
return strings.EqualFold(t, "true")
default:
return false
}
}
var arr []psDevice
if err := json.Unmarshal(out, &arr); err == nil {
res := make([]DeviceInfo, 0, len(arr))
for _, d := range arr {
res = append(res, DeviceInfo{
InstanceID: d.InstanceId,
Class: d.Class,
ClassGUID: d.ClassGuid,
FriendlyName: d.FriendlyName,
Manufacturer: d.Manufacturer,
Status: d.Status,
Present: parsePresent(d.Present),
})
}
return res, nil
}
// Ein einzelnes Objekt
var single psDevice
if err := json.Unmarshal(out, &single); err != nil {
log.Printf("getPnpDevices: cannot unmarshal: %v -- out: %s", err, string(out))
return nil, err
}
return []DeviceInfo{
{
InstanceID: single.InstanceId,
Class: single.Class,
ClassGUID: single.ClassGuid,
FriendlyName: single.FriendlyName,
Manufacturer: single.Manufacturer,
Status: single.Status,
Present: parsePresent(single.Present),
},
}, nil
}
func devicesHandler(w http.ResponseWriter, r *http.Request) {
devs, err := getPnpDevices()
if err != nil {
http.Error(w, "failed to enumerate devices: "+err.Error(), http.StatusInternalServerError)
return
}
// Optional: Filter nur USB-Geräte (InstanceID beginnt mit "USB\")
if r.URL.Query().Get("only_usb") == "1" {
filtered := make([]DeviceInfo, 0, len(devs))
for _, d := range devs {
if strings.HasPrefix(strings.ToUpper(d.InstanceID), "USB\\") {
filtered = append(filtered, d)
}
}
devs = filtered
}
w.Header().Set("Content-Type", "application/json")
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
_ = enc.Encode(devs)
}
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
func startCPUSampler() {
func startCPUSampler(ctx context.Context) {
go func() {
// Prime once
_, _ = cpu.Percent(0, false)
for {
select {
case <-ctx.Done():
return
default:
}
pcts, err := cpu.Percent(time.Second, false)
if err == nil && len(pcts) > 0 {
mu.Lock()
@@ -90,9 +460,12 @@ func getInterfaces() []NetInterface {
if err != nil {
return nil
}
// Profile holen (best effort)
profilesByIndex, _ := getWinNetProfiles()
var out []NetInterface
for _, ifc := range ifaces {
// Skip interfaces that are down
if (ifc.Flags & net.FlagUp) == 0 {
continue
}
@@ -107,23 +480,23 @@ func getInterfaces() []NetInterface {
case *net.IPAddr:
ip = v.IP
}
if ip == nil {
continue
}
if ip.IsLoopback() {
if ip == nil || ip.IsLoopback() {
continue
}
ips = append(ips, ip.String())
}
// keep even if no IPs, to show MAC etc., but skip pure loopback
if isLoop {
continue
}
profile := profilesByIndex[ifc.Index] // 👈 jetzt sicher über Index
out = append(out, NetInterface{
Name: ifc.Name,
MAC: ifc.HardwareAddr.String(),
Addresses: ips,
IsLoopback: isLoop,
Profile: profile,
})
}
return out
@@ -141,7 +514,6 @@ func getDisks() []DiskInfo {
continue
}
// read-only erkennen (gopsutil: Opts kann string ODER []string sein)
ro := false
switch opts := any(p.Opts).(type) {
case string:
@@ -200,7 +572,7 @@ func collectSummary() Summary {
Timestamp: time.Now(),
Hostname: safe(hi, func(h *host.InfoStat) string { return h.Hostname }),
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 }),
PlatformVersion: safe(hi, func(h *host.InfoStat) string { return h.PlatformVersion }),
KernelVersion: safe(hi, func(h *host.InfoStat) string { return h.KernelVersion }),
@@ -239,36 +611,124 @@ var page = template.Must(template.New("index").Parse(`<!doctype html>
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Windows Systeminfo</title>
<style>
:root { --bg:#0b1220; --fg:#e7eefc; --muted:#a3b1cc; --card:#111a2e; --accent:#5aa9ff; }
:root {
--bg: #f5f6f8;
--fg: #121826;
--muted: #5d6b82;
--card: #ffffff;
--accent: #0066ff;
--border: rgba(12, 21, 37, 0.08);
}
*{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)}
h1{font-size:22px;margin:0 0 16px 0}
body{
max-width:1200px;
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 6px 0}
h2{margin:0 0 16px 0}
.grid{display:flex;flex-direction:column;gap:12px}
.card{width:100%}
.k{font-size:12px;color:var(--muted);text-transform:uppercase;letter-spacing:.06em}
.card{
width:100%;
background:var(--card);
border:1px solid var(--border);
border-radius:16px;
padding:14px 16px 10px 16px;
box-shadow:0 10px 30px rgba(15,23,42,0.03);
}
.k{font-size:13px;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin-bottom:4px}
.v{font-weight:600}
.row{display:flex;justify-content:space-between;gap:8px;align-items:center;margin:6px 0}
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace}
.pill{display:inline-block;background:#0b2547;color:#b8d4ff;border-radius:999px;padding:2px 8px;margin:2px 6px 0 0;font-size:12px}
.bar{height:10px;background:#091428;border-radius:999px;overflow:hidden}
.fill{height:100%;background:var(--accent);width:0%}
.stereo{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;color: #ff0000ff}
.pill{
display:inline-block;
background:#e8f1ff;
color:#0047cc;
border-radius:999px;
padding:2px 8px;
margin:2px 6px 0 0;
font-size:14px;
}
.pill2{
display:inline-block;
background:#ffe480;
color:#3e3e3e;
border-radius:999px;
padding:2px 8px;
margin:2px 6px 0 0;
font-size:14px;
}
.bar{
height:10px;
background:#edf0f5;
border-radius:999px;
overflow:hidden;
}
.fill{
height:100%;
background:var(--accent);
width:0%;
transition:width .3s ease-out;
}
footer{margin-top:18px;color:var(--muted);font-size:12px}
.disks table{width:100%;border-collapse:collapse}
.disks th,.disks td{padding:8px;border-bottom:1px solid #1b2a4a;text-align:left;font-size:14px}
.disks th,.disks td{
padding:8px;
border-bottom:1px solid #e5e9f2;
text-align:left;
font-size:14px
}
.disks th{background:#f5f6f8;font-weight:500}
button#btnLoadApps{
border:none;
background:#e8f1ff;
color:#0047cc;
font-weight:500;
}
input#appsFilter{
background:#ffffff;
color:var(--fg);
border:1px solid #d0d7e2;
border-radius:8px;
}
table thead th{font-size:13px;color:#3b4560}
@media (prefers-color-scheme: dark) {
/* optional: falls Host dunkles Theme erzwingt */
:root {
--bg:#f5f6f8;
--fg:#121826;
--card:#ffffff;
}
}
</style>
</head>
<body>
<h1>Windows Systeminfo</h1>
<h2><div class="v mono" id="hostname2"></div></h2>
<h2><div class="v stereo" id="hostname2"></div></h2>
<div class="grid">
<div class="card">
<div class="k">Host</div>
<div class="row"><div>Hostname</div><div class="v mono" id="hostname"></div></div>
<div class="row"><div>User</div><div class="v mono" id="username"></div></div>
<div class="row"><div>Uptime</div><div class="v" id="uptime"></div></div>
<div class="row"><div>Boot</div><div class="v" id="boottime"></div></div>
<div class="row"><div>Arch</div><div class="v mono" id="arch"></div></div>
</div>
<div class="k">Host</div>
<div class="row"><div>Hostname</div><div class="v stereo" id="hostname"></div></div>
<div class="row"><div>User</div><div class="v mono" id="username"></div></div>
<div class="row"><div>Uptime</div><div class="v" id="uptime"></div></div>
<div class="row"><div>Boot</div><div class="v" id="boottime"></div></div>
<div class="row"><div>Arch</div><div class="v mono" id="arch"></div></div>
<div class="row" style="margin-top:8px;">
<div>Aktionen</div>
<div>
<button id="btnGpupdate" class="pill2" style="cursor:pointer;">gpupdate /force</button>
</div>
</div>
<div class="row">
<div></div>
<div class="mono" id="gpupdateResult" style="font-size:12px;color:var(--muted);white-space:pre-wrap;"></div>
</div>
</div>
<div class="card">
<div class="k">OS</div>
@@ -306,6 +766,28 @@ var page = template.Must(template.New("index").Parse(`<!doctype html>
<tbody id="disks"></tbody>
</table>
</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;">
<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 #e5e9f2">Name</th>
<th style="text-align:left;padding:8px;border-bottom:1px solid #e5e9f2">Version</th>
<th style="text-align:left;padding:8px;border-bottom:1px solid #e5e9f2">Publisher</th>
<th style="text-align:left;padding:8px;border-bottom:1px solid #e5e9f2">Quelle</th>
</tr>
</thead>
<tbody id="apps"></tbody>
</table>
</div>
</div>
</div>
<footer>Aktualisiert alle 2s</footer>
@@ -321,7 +803,7 @@ async function load(){
const j = await r.json();
document.getElementById('hostname').textContent = j.hostname || '-';
document.getElementById('hostname2').textContent = j.hostname || '-';
document.getElementById('hostname2').textContent = j.hostname || '-';
document.getElementById('username').textContent = j.username || '-';
document.getElementById('uptime').textContent = hms(j.uptime_seconds);
document.getElementById('boottime').textContent = j.boot_time ? new Date(j.boot_time).toLocaleString() : '-';
@@ -347,6 +829,12 @@ async function load(){
(j.interfaces||[]).forEach(n=>{
const div = document.createElement('div');
div.innerHTML = '<div class="row"><div>'+n.name+'</div><div class="mono">'+(n.mac||'-')+'</div></div>';
if (n.profile) {
const p = document.createElement('span');
p.className = 'pill2';
p.textContent = n.profile;
div.appendChild(p);
}
(n.addresses||[]).forEach(ip=>{ const span=document.createElement('span'); span.className='pill mono'; span.textContent=ip; div.appendChild(span); });
wrap.appendChild(div);
});
@@ -362,9 +850,81 @@ async function load(){
load();
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 #e5e9f2">'+(a.name||'-')+'</td>'+
'<td style="padding:6px 8px;border-bottom:1px solid #e5e9f2">'+(a.version||'')+'</td>'+
'<td style="padding:6px 8px;border-bottom:1px solid #e5e9f2">'+(a.publisher||'')+'</td>'+
'<td style="padding:6px 8px;border-bottom:1px solid #e5e9f2">'+(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;
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);
});
}
const btnGp = document.getElementById('btnGpupdate');
const resGp = document.getElementById('gpupdateResult');
if (btnGp && resGp) {
btnGp.addEventListener('click', async ()=>{
resGp.textContent = 'gpupdate /force wird gestartet …';
try {
const r = await fetch('/api/gpupdate', { method: 'POST' });
if (!r.ok) {
resGp.textContent = 'Fehler: HTTP ' + r.status + ' ' + r.statusText;
return;
}
const j = await r.json();
if (j.ok) {
resGp.textContent = 'gpupdate /force ausgeführt (ExitCode ' + (j.exit_code ?? 0) + ').';
} else {
resGp.textContent = 'Fehler bei gpupdate (ExitCode ' + (j.exit_code ?? -1) + '): ' + (j.error || '');
}
} catch (e) {
resGp.textContent = 'Request-Fehler: ' + e;
}
});
}
});
</script>
</body>
</html>`))
</html>
`))
func indexHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
@@ -383,16 +943,233 @@ func apiHandler(w http.ResponseWriter, r *http.Request) {
}
}
func main() {
startCPUSampler()
// --- ab hier: Dienst-Integration ---
const serviceName = "GoSysInfoService"
// runHTTP startet deinen bisherigen HTTP-Server und reagiert auf ctx.Done()
func runHTTP(ctx context.Context) error {
mux := http.NewServeMux()
mux.HandleFunc("/", indexHandler)
mux.HandleFunc("/api/apps", appsHandler)
mux.HandleFunc("/api/summary", apiHandler)
mux.HandleFunc("/api/devices", devicesHandler)
mux.HandleFunc("/api/gpupdate", gpupdateHandler)
mux.HandleFunc("/api/notify", notifyFromServerHandler)
mux.HandleFunc("/api/notifications", notificationsForAgentHandler)
addr := ":24000"
srv := &http.Server{
Addr: addr,
Handler: mux,
}
// CPU-Sampler an das gleiche ctx hängen
startCPUSampler(ctx)
addr := "127.0.0.1:8080" // bind strictly to loopback (IPv4)
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)
}
}
type psConnProfile struct {
Name string `json:"Name"`
InterfaceAlias string `json:"InterfaceAlias"`
InterfaceIndex int `json:"InterfaceIndex"`
NetworkCategory string `json:"NetworkCategory"` // "Public", "Private", "DomainAuthenticated"
}
func getWinNetProfiles() (map[int]string, error) {
cmd := exec.Command(
"powershell",
"-NoProfile",
"-NonInteractive",
"-Command",
`Get-NetConnectionProfile | Select-Object InterfaceAlias,InterfaceIndex,NetworkCategory | ConvertTo-Json -Depth 3`,
)
out, err := cmd.Output()
if err != nil {
log.Printf("getWinNetProfiles: powershell error: %v", err)
return nil, err
}
if len(out) == 0 {
return map[int]string{}, nil
}
// Wir wissen nicht, ob PS ein Objekt oder ein Array zurückgibt → erst Array versuchen
type psConnProfile struct {
InterfaceAlias string `json:"InterfaceAlias"`
InterfaceIndex int `json:"InterfaceIndex"`
NetworkCategory interface{} `json:"NetworkCategory"`
}
normalizeCat := func(v interface{}) string {
switch vv := v.(type) {
case string:
// "Public", "Private", "DomainAuthenticated"
return vv
case float64:
switch int(vv) {
case 0:
return "Public"
case 1:
return "Private"
case 2:
return "DomainAuthenticated"
default:
return "Unknown"
}
default:
return ""
}
}
// 1) Array probieren
var arr []psConnProfile
if err := json.Unmarshal(out, &arr); err == nil {
res := make(map[int]string, len(arr))
for _, p := range arr {
res[p.InterfaceIndex] = normalizeCat(p.NetworkCategory)
}
return res, nil
}
// 2) Einzelnes Objekt probieren
var single psConnProfile
if err := json.Unmarshal(out, &single); err != nil {
log.Printf("getWinNetProfiles: cannot unmarshal: %v -- out: %s", err, string(out))
return nil, err
}
res := make(map[int]string, 1)
res[single.InterfaceIndex] = normalizeCat(single.NetworkCategory)
return res, nil
}
type GPUpdateResponse struct {
OK bool `json:"ok"`
ExitCode int `json:"exit_code"`
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
Error string `json:"error,omitempty"`
}
func gpupdateHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// nur lokale Aufrufe zulassen
host, _, _ := net.SplitHostPort(r.RemoteAddr)
if host != "127.0.0.1" && host != "::1" {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
defer cancel()
cmd := exec.CommandContext(ctx, "cmd", "/C", "gpupdate", "/force")
out, err := cmd.CombinedOutput()
resp := GPUpdateResponse{
Stdout: string(out),
Stderr: "",
OK: true,
}
// Timeout?
if ctx.Err() == context.DeadlineExceeded {
resp.OK = false
resp.Error = "gpupdate /force: Timeout (2 Minuten)"
}
// Exitcode auswerten
if err != nil {
resp.OK = false
resp.Error = err.Error()
if exitErr, ok := err.(*exec.ExitError); ok {
if status, ok2 := exitErr.Sys().(syscall.WaitStatus); ok2 {
resp.ExitCode = status.ExitStatus()
}
}
} else if cmd.ProcessState != nil {
if status, ok := cmd.ProcessState.Sys().(syscall.WaitStatus); ok {
resp.ExitCode = status.ExitStatus()
}
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}

BIN
pcinfo.exe Normal file

Binary file not shown.