Compare commits
6 Commits
81b1c326fc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b1e0bd9548 | |||
| 07fa8d6020 | |||
| 33e0b73f7f | |||
| f3bd8d0910 | |||
| d513992de7 | |||
| 9a5ed02a8c |
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"additionalDirectories": [
|
||||
"c:\\Users\\gruessing\\OneDrive - Cereda Systems GmbH\\Desktop\\ITNexusScanner\\src\\types",
|
||||
"c:\\Users\\gruessing\\OneDrive - Cereda Systems GmbH\\Desktop\\ITNexusScanner\\src\\services",
|
||||
"c:\\Users\\gruessing\\OneDrive - Cereda Systems GmbH\\Desktop\\ITNexusScanner\\src\\components\\common",
|
||||
"c:\\Users\\gruessing\\OneDrive - Cereda Systems GmbH\\Desktop\\ITNexusScanner\\*",
|
||||
"c:\\Users\\gruessing\\OneDrive - Cereda Systems GmbH\\Desktop\\ITNexusScanner\\src\\screens",
|
||||
"c:\\Users\\gruessing\\OneDrive - Cereda Systems GmbH\\Desktop\\ITNexusScanner",
|
||||
"c:\\Users\\gruessing\\OneDrive - Cereda Systems GmbH\\Desktop\\it_nexus_scanner\\lib\\utils",
|
||||
"c:\\Users\\gruessing\\OneDrive - Cereda Systems GmbH\\Desktop\\it_nexus_scanner\\lib\\models",
|
||||
"c:\\Users\\gruessing\\OneDrive - Cereda Systems GmbH\\Desktop\\it_nexus_scanner\\lib\\services",
|
||||
"c:\\Users\\gruessing\\OneDrive - Cereda Systems GmbH\\Desktop\\it_nexus_scanner\\lib\\widgets",
|
||||
"c:\\Users\\gruessing\\OneDrive - Cereda Systems GmbH\\Desktop\\it_nexus_scanner\\lib\\screens",
|
||||
"c:\\Users\\gruessing\\OneDrive - Cereda Systems GmbH\\Desktop\\it_nexus_scanner\\lib",
|
||||
"c:\\Users\\gruessing\\OneDrive - Cereda Systems GmbH\\Desktop\\it_nexus_scanner\\android\\app\\src\\main",
|
||||
"c:\\gradle-home",
|
||||
"c:\\Users\\gruessing\\OneDrive - Cereda Systems GmbH\\Desktop\\it_nexus_scanner"
|
||||
],
|
||||
"allow": [
|
||||
"WebFetch(domain:docs.arcticwolf.com)"
|
||||
]
|
||||
},
|
||||
"enabledPlugins": {
|
||||
"playwright-skill@playwright-skill": true
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"*",
|
||||
"Bash(*)",
|
||||
"Bash(node -e:*)",
|
||||
"Bash(test:*)",
|
||||
"Bash(npm run dev:*)",
|
||||
"Bash(npm cache clean:*)",
|
||||
"Bash(docker-compose ps:*)",
|
||||
"Bash(docker-compose logs:*)",
|
||||
"Bash(docker-compose up:*)",
|
||||
"Bash(curl:*)",
|
||||
"WebFetch(domain:cereda-systems.de)",
|
||||
"Bash(docker-compose down:*)",
|
||||
"Bash(ls:*)",
|
||||
"Bash(docker-compose build:*)",
|
||||
"Bash(docker-compose restart:*)",
|
||||
"Bash(docker exec:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
26
.env.example
Normal file
26
.env.example
Normal file
@@ -0,0 +1,26 @@
|
||||
# Kopieren nach .env und mit echten Werten befüllen — .env ist in .gitignore, NIEMALS committen!
|
||||
|
||||
JWT_SECRET=
|
||||
AGENT_API_KEY=
|
||||
SCANNER_API_KEY=
|
||||
ENCRYPTION_KEY=
|
||||
TV_DASHBOARD_KEY=
|
||||
|
||||
# Microsoft 365 Mailbox (it-tool@cereda-systems.de)
|
||||
SMTP_PASSWORD=
|
||||
IMAP_PASSWORD=
|
||||
|
||||
# Microsoft Azure AD App-Registrierung
|
||||
AZURE_CLIENT_SECRET=
|
||||
|
||||
# Anthropic Claude API
|
||||
ANTHROPIC_API_KEY=
|
||||
|
||||
# Teams Bot
|
||||
TEAMS_BOT_APP_SECRET=
|
||||
|
||||
# Proxmox API-Token
|
||||
PROXMOX_TOKEN=
|
||||
|
||||
# GitHub PAT (Feedback-System)
|
||||
GITHUB_TOKEN=
|
||||
64
.github/workflows/README.md
vendored
64
.github/workflows/README.md
vendored
@@ -1,64 +0,0 @@
|
||||
# GitHub Actions — Deploy Workflow
|
||||
|
||||
## Übersicht
|
||||
|
||||
Der Workflow `deploy.yml` läuft automatisch bei jedem Push auf `main`.
|
||||
|
||||
- **Job 1 `build`**: Installiert Dependencies und baut das Frontend — rein zum Kompilierungs-Check.
|
||||
- **Job 2 `deploy`**: Kopiert den Quellcode per SCP auf den Server und startet Docker neu. **Erfordert manuellen Approval** über das GitHub Environment `production`.
|
||||
|
||||
---
|
||||
|
||||
## 1. SSH Key als Secret hinterlegen
|
||||
|
||||
### SSH Key generieren (falls noch kein dedizierter Key vorhanden)
|
||||
|
||||
```bash
|
||||
ssh-keygen -t ed25519 -C "github-actions-itnexus" -f ~/.ssh/github_actions_itnexus
|
||||
```
|
||||
|
||||
Den Public Key auf dem Server hinterlegen:
|
||||
|
||||
```bash
|
||||
cat ~/.ssh/github_actions_itnexus.pub | ssh root@192.168.0.194 "cat >> ~/.ssh/authorized_keys"
|
||||
```
|
||||
|
||||
### Secrets in GitHub eintragen
|
||||
|
||||
Gehe zu: **Repository → Settings → Secrets and variables → Actions → New repository secret**
|
||||
|
||||
| Secret Name | Wert |
|
||||
|---|---|
|
||||
| `SSH_PRIVATE_KEY` | Inhalt von `~/.ssh/github_actions_itnexus` (Private Key, beginnt mit `-----BEGIN OPENSSH PRIVATE KEY-----`) |
|
||||
| `SERVER_IP` | `192.168.0.194` |
|
||||
|
||||
---
|
||||
|
||||
## 2. Production Environment mit Required Reviewer einrichten
|
||||
|
||||
### Environment erstellen
|
||||
|
||||
1. Gehe zu: **Repository → Settings → Environments**
|
||||
2. Klicke auf **New environment**
|
||||
3. Name: `production` (exakt so, wie im Workflow hinterlegt)
|
||||
4. Klicke auf **Configure environment**
|
||||
|
||||
### Required Reviewers setzen
|
||||
|
||||
1. Aktiviere **Required reviewers**
|
||||
2. Füge `Simon Grüssing` (GitHub-Username) als Reviewer hinzu
|
||||
3. Optional: **Prevent self-review** aktivieren wenn ein weiterer Reviewer vorhanden ist
|
||||
4. Speichern mit **Save protection rules**
|
||||
|
||||
---
|
||||
|
||||
## 3. Approval-Prozess
|
||||
|
||||
1. Push auf `main` → Job `build` startet automatisch und läuft durch.
|
||||
2. Nach erfolgreichem Build: Job `deploy` wartet auf Approval.
|
||||
3. GitHub schickt eine **E-Mail-Benachrichtigung** an alle eingetragenen Reviewer.
|
||||
4. Reviewer klickt in der E-Mail oder direkt im GitHub Actions Tab auf den Workflow-Run.
|
||||
5. Unter **"This workflow run is waiting for a required review"** → **Review deployments** → Haken bei `production` setzen → **Approve and deploy**.
|
||||
6. Erst dann startet der Deployment-Job.
|
||||
|
||||
> Tipp: Den Workflow-Status siehst du unter **Repository → Actions**.
|
||||
68
.github/workflows/deploy.yml
vendored
68
.github/workflows/deploy.yml
vendored
@@ -1,68 +0,0 @@
|
||||
name: IT Nexus Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
deploy:
|
||||
description: 'Manuell deployen?'
|
||||
required: true
|
||||
default: 'yes'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Frontend
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js 18
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: frontend
|
||||
run: npm install
|
||||
|
||||
- name: Build frontend
|
||||
working-directory: frontend
|
||||
run: npm run build
|
||||
env:
|
||||
CI: false
|
||||
|
||||
deploy:
|
||||
name: Deploy to Production
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
environment: production
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup SSH key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa
|
||||
chmod 600 ~/.ssh/id_rsa
|
||||
ssh-keyscan -H ${{ secrets.SERVER_IP }} >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Deploy frontend/src to server
|
||||
run: |
|
||||
scp -r frontend/src root@${{ secrets.SERVER_IP }}:/opt/it-nexus/frontend/src
|
||||
|
||||
- name: Deploy backend/src to server
|
||||
run: |
|
||||
scp -r backend/src root@${{ secrets.SERVER_IP }}:/opt/it-nexus/backend/src
|
||||
|
||||
- name: Rebuild and restart Docker containers
|
||||
run: |
|
||||
ssh root@${{ secrets.SERVER_IP }} "cd /opt/it-nexus && docker compose build frontend backend && docker compose up -d frontend backend"
|
||||
16
.gitignore
vendored
16
.gitignore
vendored
@@ -48,7 +48,15 @@ installer/
|
||||
*.intunewin
|
||||
*.ova
|
||||
*.exe
|
||||
installer/
|
||||
*.intunewin
|
||||
*.ova
|
||||
*.exe
|
||||
|
||||
# Nicht Teil der IT-Nexus-App (separate Tools/Demos/Scratch — nur lokal)
|
||||
.claude/
|
||||
.github/
|
||||
agent/
|
||||
domain-join-tool/
|
||||
nexus-scanner/
|
||||
playwright-tests/
|
||||
*.docx
|
||||
*.zip
|
||||
*-Demo.html
|
||||
demo-*.html
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 12 KiB |
@@ -1,190 +0,0 @@
|
||||
# AUDIT: IT Nexus Windows Agent v2.0.0
|
||||
**Datum:** 06.05.2026
|
||||
**Geprüft von:** Claude (automatisierter Audit)
|
||||
**Status:** PHASE 2 abgeschlossen — wartet auf Freigabe für Phase 3/4
|
||||
|
||||
---
|
||||
|
||||
## Architektur-Übersicht
|
||||
|
||||
```
|
||||
IT-NB-02 (Windows Service "IT Nexus Agent")
|
||||
│
|
||||
├── POST /api/monitoring/checkin (alle 1 Min, X-Agent-Key Auth)
|
||||
│ → sendet: Systeminformationen, Software, Updates, BitLocker, Defender, Serial
|
||||
│ ← empfängt: agent_version, commands[], announcements[]
|
||||
│
|
||||
├── POST /api/patch/commands/result (nach jedem Command, X-Agent-Key Auth)
|
||||
│
|
||||
├── POST /api/announcements/:id/ack-agent (nach Klick auf "Gelesen und bestätigt")
|
||||
│
|
||||
└── GET /api/monitoring/agent-setup (für Auto-Update, X-Agent-Key Auth)
|
||||
```
|
||||
|
||||
**Kommunikation:** REST/HTTP, kein WebSocket, kein Push vom Server.
|
||||
**Auth:** X-Agent-Key Header bei allen Agent-Endpoints.
|
||||
**Checkin-Intervall:** 1 Minute (while-loop mit Task.Delay).
|
||||
**Announcement-Dispatch:** Schtasks als eingeloggter User → WPF-Dialog → ACK bei Klick.
|
||||
|
||||
---
|
||||
|
||||
## Komponenten-Status
|
||||
|
||||
| Komponente | Status | Anmerkung |
|
||||
|---|---|---|
|
||||
| Windows Service (IT Nexus Agent) | ✅ OK | Läuft auf IT-NB-02, checkt jede Minute ein |
|
||||
| Checkin-Endpoint | ✅ OK | Daten kommen an, werden gespeichert |
|
||||
| OS-Felderkennung | ✅ OK | Windows 11 korrekt (Build-Nummer Fix) |
|
||||
| CPU/RAM/Disk-Daten | ✅ OK | Nach MonitoringAgent.js Deploy (heute) |
|
||||
| BitLocker/Defender/Serial | ✅ OK | Korrekt gemeldet |
|
||||
| Patch Commands | ✅ OK | check_updates, install_updates, reboot funktionieren |
|
||||
| Auto-Update Mechanismus | ✅ OK | Lädt vollständigen Installer, führt ihn silent aus |
|
||||
| Ankündigungs-Anzeige | ✅ OK | WPF-Dialog erscheint beim Checkin |
|
||||
| Ankündigungs-ACK | ✅ OK | Wird beim Klick auf "Gelesen und bestätigt" gesendet |
|
||||
| Intune-Erkennungsregel | ✅ OK | _is1 Suffix, 32-Bit: Nein |
|
||||
| Server AGENT_VERSION | ✅ OK | = 2.0.0 (heute gesetzt) |
|
||||
| MonitoringAgent.js (Backend-Model) | ✅ OK | Dual-Mapping heute deployed |
|
||||
|
||||
---
|
||||
|
||||
## Gefundene und behobene Probleme (heute)
|
||||
|
||||
### BEHOBEN ✅ — MonitoringAgent.js nicht deployt (KRITISCH)
|
||||
**Was:** Server hatte altes Model ohne Fallback-Mapping für C#-Feldnamen (`data.os_name || null` statt `data.os_name || data.os || null`).
|
||||
**Folge:** os_name, cpu_usage, ram_total, disk_total waren NULL in der DB für alle C#-Agent-Geräte.
|
||||
**Fix:** MonitoringAgent.js auf Server deployt (10:24 Uhr).
|
||||
**Verifiziert:** DB zeigt jetzt `Windows 11 Pro (Build 26100.8246)`, cpu_usage_percent=6.4, ram_total_gb=31.28.
|
||||
|
||||
### BEHOBEN ✅ — Announcement-Task feuert wiederholt (HOCH)
|
||||
**Was:** Schtasks-Task wurde mit Trigger +60 Minuten erstellt und nach `/run` nicht gelöscht.
|
||||
**Folge:** Popup erschien 60 Minuten später erneut, auch nach "Gelesen und bestätigt".
|
||||
**Fix:** Task wird jetzt 3 Sekunden nach Ausführung gelöscht.
|
||||
|
||||
### BEHOBEN ✅ — Falscher User-Domain für Schtasks (HOCH)
|
||||
**Was:** `Environment.UserDomainName` gibt als SYSTEM den Maschinennamen zurück, nicht die Domain.
|
||||
**Folge:** Schtasks-Task mit `IT-NB-02\gruessing` statt `WINKEL\gruessing` → Task würde fehlschlagen.
|
||||
**Fix:** Vollständigen User direkt aus `Win32_ComputerSystem.UserName` (WMI) holen.
|
||||
|
||||
### BEHOBEN ✅ — Auto-Update ersetzte nur EXE, nicht DLL (KRITISCH)
|
||||
**Was:** UpdateAgent downloadete PS1-Script statt EXE, und ersetzte nur die EXE. In .NET 8 liegt der Code in der DLL.
|
||||
**Folge:** Auto-Update hätte nicht funktioniert.
|
||||
**Fix:** DownloadSetupAsync lädt den vollständigen Installer, führt ihn `/VERYSILENT` aus.
|
||||
|
||||
### BEHOBEN ✅ — Alte Scheduled Tasks nicht entfernt bei Installation (MITTEL)
|
||||
**Was:** Installer entfernte nicht die alten PS-Agent-Tasks (`schtasks /delete`).
|
||||
**Folge:** Alter Agent lief weiter parallel zum neuen Service.
|
||||
**Fix:** setup.iss entfernt jetzt alle bekannten Task-Namen beim Install.
|
||||
|
||||
---
|
||||
|
||||
## Alle Fixes implementiert (06.05.2026)
|
||||
|
||||
| Fix | Datei | Status |
|
||||
|---|---|---|
|
||||
| 1 Win11-Upgrade Domain-Präfix | CommandExecutor.cs + NotificationService.cs | ✅ |
|
||||
| 2 HTTP-Fehler EnsureSuccessStatusCode | ApiService.cs | ✅ |
|
||||
| 3 Versions-Vergleich semantisch | AgentWorker.cs | ✅ |
|
||||
| 4 _shownIds in shown_announcements.json persistiert | NotificationService.cs | ✅ |
|
||||
| 5 Playwright-Selektoren robuster | playwright-tests/check.js | ✅ |
|
||||
| 6 config.json ACL auf SYSTEM+Admins | AgentWorker.cs SecureConfigFile() | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## Verbleibende Probleme & Risiken
|
||||
|
||||
### ✅ BEHOBEN — Announcement _shownIds nicht persistiert
|
||||
**Fix:** Wird jetzt in `C:\ProgramData\IT Nexus Agent\shown_announcements.json` gespeichert und beim Start geladen.
|
||||
|
||||
### ✅ BEHOBEN — Version-Vergleich als String
|
||||
**Fix:** `System.Version.TryParse()` + `serverVer > localVer` — semantischer Vergleich.
|
||||
|
||||
### ✅ BEHOBEN — Win11-Upgrade-Task nutzt noch alten Domain-Präfix
|
||||
**Fix:** Verwendet jetzt `NotificationService.GetLoggedOnUser()` (WMI-basiert).
|
||||
|
||||
### ✅ BEHOBEN — HttpClient nicht für Fehler geprüft
|
||||
**Fix:** `EnsureSuccessStatusCode()` in CheckinAsync, AckAnnouncementAsync, ReportCommandResultAsync.
|
||||
|
||||
### ✅ BEHOBEN — config.json lesbar für alle lokalen User
|
||||
**Fix:** `SecureConfigFile()` in AgentWorker.RunAsync() setzt ACL auf SYSTEM + Administrators only bei jedem Start.
|
||||
|
||||
### ✅ BEHOBEN — Monitoring-Modal-Button in Playwright nicht gefunden
|
||||
**Fix:** Robuste Selektor-Kette mit 4 Fallbacks in check.js.
|
||||
|
||||
### ❌ OFFEN — MonitoringAgent.js Dual-Mapping nicht in Container committed
|
||||
**Beschreibung:** MonitoringAgent.js wurde per `docker cp` in den Container kopiert, aber nicht in `docker compose build` eingebaut.
|
||||
**Folge:** Beim nächsten `docker compose build backend` wird die alte Version wieder aus dem Image gebaut.
|
||||
**Risiko:** HOCH — nach dem nächsten vollständigen Backend-Rebuild sind os_name etc. wieder NULL.
|
||||
**Fix:** Lokale Datei bereits korrekt, Server-Datei bereits korrekt — beim nächsten Build wird die Datei aus `/opt/it-nexus/backend/src/models/MonitoringAgent.js` gelesen, die heute aktualisiert wurde. ✅ Kein Problem.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Endpoint-Tests (06.05.2026, ~12:50 Uhr)
|
||||
|
||||
| Endpoint | Test | Ergebnis |
|
||||
|---|---|---|
|
||||
| POST /api/monitoring/checkin | Gültiger Key | ✅ HTTP 200, alle Felder korrekt gespeichert |
|
||||
| POST /api/monitoring/checkin | Falscher Key | ✅ HTTP 401 |
|
||||
| POST /api/patch/commands/result | Gültiger Key | ✅ HTTP 200 |
|
||||
| POST /api/patch/commands/result | Falscher Key | ✅ HTTP 401 (war vorher 200 — BEHOBEN) |
|
||||
| POST /api/monitoring/announcements-poll | Gültiger Key | ✅ `{"announcements":[]}` (war "Access token required" — BEHOBEN) |
|
||||
| POST /api/monitoring/announcements-poll | Falscher Key | ✅ HTTP 401 |
|
||||
| GET /api/monitoring/agent-setup | Gültiger Key | ✅ HTTP 200, 2.4MB EXE |
|
||||
| GET /api/monitoring/agent-setup | Kein Key | ✅ HTTP 401 |
|
||||
| POST /api/announcements/:id/ack-agent | Gültiger Key | ✅ Bestätigt per Code-Review |
|
||||
|
||||
**Zusätzlicher Fund während Phase 3:** `releaseVersionToAll` fehlte in lokal deployed controller.js → Backend crashte. Sofort behoben.
|
||||
|
||||
---
|
||||
|
||||
## Live-Verifikation (Stand 06.05.2026, ~12:30 Uhr)
|
||||
|
||||
| Test | Ergebnis |
|
||||
|---|---|
|
||||
| Service auf IT-NB-02 läuft | ✅ Running |
|
||||
| Checkin alle ~1 Minute | ✅ Log zeigt regelmäßige Checkins |
|
||||
| DB: os_name = "Windows 11 Pro (Build 26100.8246)" | ✅ |
|
||||
| DB: cpu_usage_percent = 6.4 | ✅ |
|
||||
| DB: ram_total_gb = 31.28 | ✅ |
|
||||
| DB: bitlocker_status = "encrypted" | ✅ |
|
||||
| DB: hardware_serial = "5CD44318HR" | ✅ |
|
||||
| Playwright: Login | ✅ |
|
||||
| Playwright: Dashboard | ✅ |
|
||||
| Playwright: Monitoring API (IT-NB-02) | ✅ |
|
||||
| Playwright: BitLocker encrypted | ✅ |
|
||||
| Playwright: Defender aktiv | ✅ |
|
||||
| Playwright: Serial 5CD44318HR | ✅ |
|
||||
| Playwright: Win11-Badge = 🪟 | ✅ |
|
||||
| Playwright: v2.0.0 in Patch Management | ✅ |
|
||||
| Playwright: Test-Gruppe 1/1 aktualisiert | ✅ |
|
||||
| Playwright: Helpdesk | ✅ |
|
||||
| Playwright: KI-Assistent | ✅ |
|
||||
| Playwright: OS = Windows 11 | ⚠️ War null (behoben), nach nächstem Test OK |
|
||||
| Playwright: Detail-Modal | ⚠️ Selektor passt nicht (UI-Bug in Test) |
|
||||
| Ankündigung → WPF-Dialog erscheint | ✅ Getestet |
|
||||
| Ankündigung → ACK bei Klick | ✅ Code korrekt |
|
||||
| Intune: Erkennungsregel | ✅ _is1 Suffix, 32-Bit: Nein |
|
||||
| Auto-Update: lädt Installer | ✅ DownloadSetupAsync korrekt |
|
||||
|
||||
**Gesamt: 22/24 ✅, 2/24 ⚠️ (minor)**
|
||||
|
||||
---
|
||||
|
||||
## Reparatur-Plan (Prioritäten)
|
||||
|
||||
| Prio | Was | Risiko wenn nicht gefixt | Aufwand |
|
||||
|---|---|---|---|
|
||||
| 1 | Win11-Upgrade-Task Domain-Fix | Win11-Upgrade scheitert für Entra-ID-User | 5 Min |
|
||||
| 2 | HttpClient Status-Prüfung | Fehler werden still geschluckt | 15 Min |
|
||||
| 3 | Version-Vergleich mit Version.Parse() | Fehler ab v2.0.10 | 5 Min |
|
||||
| 4 | _shownIds in status.json persistieren | Doppelte Popups bei Neustart | 15 Min |
|
||||
| 5 | Playwright-Selektor für Modal | Kein automatisierter Modal-Test | 10 Min |
|
||||
| 6 | config.json ACL setzen | Agent-Key-Exposure auf kompromittierten Systemen | 10 Min |
|
||||
|
||||
---
|
||||
|
||||
## Fazit
|
||||
|
||||
**Das System ist produktionstauglich.**
|
||||
Der kritische Bug (MonitoringAgent.js Feldmapping) wurde heute behoben und verifiziert. Alle Kernfunktionen laufen korrekt. Die verbleibenden 6 Punkte sind Verbesserungen, kein Showstopper.
|
||||
|
||||
**Empfehlung:** Prio-1 und Prio-2 vor Produktions-Rollout fixen (je ~5-15 Min). Danach freigeben.
|
||||
1524
Assets-Demo.html
1524
Assets-Demo.html
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
29
IT Tool.sln
29
IT Tool.sln
@@ -1,29 +0,0 @@
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.5.2.0
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "agent-cs", "agent-cs", "{80D118B3-826D-1EA8-FFCB-9D6851D86064}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IT-Nexus-Agent", "agent-cs\IT-Nexus-Agent.csproj", "{5B33A971-38EB-521D-F0EA-7EACC8511EB9}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{5B33A971-38EB-521D-F0EA-7EACC8511EB9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{5B33A971-38EB-521D-F0EA-7EACC8511EB9}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{5B33A971-38EB-521D-F0EA-7EACC8511EB9}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{5B33A971-38EB-521D-F0EA-7EACC8511EB9}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{5B33A971-38EB-521D-F0EA-7EACC8511EB9} = {80D118B3-826D-1EA8-FFCB-9D6851D86064}
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {ABDE2D4F-9A27-4FA1-8951-C75147FF36AC}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -1,216 +0,0 @@
|
||||
# IT Nexus System Audit — 07.05.2026
|
||||
**Erstellt von:** Claude (Read-Only, keine Änderungen)
|
||||
**Status:** Offen — noch nicht bearbeitet
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Das System läuft stabil. Frontend und Backend sind erreichbar, die letzten 10+ Tage alle grün im Health-Check. Es gibt aber konkrete Probleme in den Bereichen Sicherheit, Konsistenz und Wartbarkeit.
|
||||
|
||||
---
|
||||
|
||||
## 🟢 Was gut ist
|
||||
|
||||
| Bereich | Status |
|
||||
|---|---|
|
||||
| System erreichbar (HTTPS) | ✅ |
|
||||
| Health: letzte 10 Tage | ✅ alle grün |
|
||||
| Agent-Auth (X-Agent-Key) | ✅ korrekt |
|
||||
| JWT-Auth auf geschützten Routes | ✅ korrekt |
|
||||
| Rate-Limiting auf Login | ✅ vorhanden |
|
||||
| DB-Migrations (try/catch) | ✅ idempotent |
|
||||
| asyncHandler auf Controllern | ✅ konsistent |
|
||||
|
||||
---
|
||||
|
||||
## 🔴 Kritische Probleme (sofort)
|
||||
|
||||
### 1. Agent-Version Default falsch
|
||||
- `AGENT_VERSION` Fallback im Code noch auf `'1.2.4'` — zieht sich durch 3 Stellen im Controller
|
||||
- **Fix:** In docker-compose.yml `AGENT_VERSION=2.0.0` — bereits gesetzt ✅. Aber Fallback im Code noch `'1.2.4'` und `'1.2.3'` — sollte auf `'2.0.0'` geändert werden
|
||||
- **Dateien:** `backend/src/controllers/monitoringAgent.controller.js` Zeilen 25, 113, 136
|
||||
|
||||
### 2. `/api/monitoring/agent-setup` ohne vollständige Auth
|
||||
- Endpoint prüft Key im Controller, aber Route liegt vor Middleware — unkonventionell
|
||||
- Kein HTTPS-erzwungener Download
|
||||
|
||||
### 3. SSE-Endpoints mit Token im Query-Parameter
|
||||
- `/api/tickets/:id/events?token=xxx` — Token landet in Server-Logs und Browser-History
|
||||
- `/api/network-monitor/sse?token=xxx` — gleiches Problem
|
||||
- **Dateien:** `backend/src/routes/ticket.routes.js`, `backend/src/routes/networkMonitor.routes.js`
|
||||
|
||||
---
|
||||
|
||||
## 🟡 Hohe Priorität (diese Woche)
|
||||
|
||||
### 4. Kein Rate-Limiting außer Login
|
||||
- `POST /api/tickets/public` — jeder kann Tickets erstellen, keine Limits
|
||||
- `POST /api/tickets/:id/comments` — Spam möglich
|
||||
- **Datei:** `backend/src/server.js` Zeilen 64-74
|
||||
|
||||
### 5. Keine Pagination auf großen Endpoints
|
||||
- `GET /api/users` → alle User auf einmal
|
||||
- `GET /api/assets` → alle Assets auf einmal
|
||||
- `GET /api/tickets` → alle Tickets
|
||||
- Bei Wachstum wird das ein Performance-Problem
|
||||
|
||||
### 6. E-Mail-Fehler werden ignoriert
|
||||
- `.catch(err => console.error())` ohne Retry
|
||||
- Bestätigungs-Mails, Onboarding-Mails könnten lautlos verloren gehen
|
||||
- **Dateien:** `backend/src/controllers/ticket.controller.js` Zeilen 131, 137, 178
|
||||
|
||||
### 7. Kein Graceful Shutdown der DB
|
||||
- SQLite wird beim Neustart nicht sauber geschlossen → mögliche Locks
|
||||
- **Datei:** `backend/src/server.js` (fehlt: `process.on('SIGTERM')` Handler)
|
||||
|
||||
---
|
||||
|
||||
## 🟠 Mittlere Priorität (diesen Monat)
|
||||
|
||||
### 8. Kein strukturiertes Logging
|
||||
- Überall `console.log/error` — kein Log-Level, kein JSON-Format
|
||||
- Schwer zu debuggen in Produktion
|
||||
- **Empfehlung:** Winston oder Pino einführen
|
||||
|
||||
### 9. Planner-Sync läuft alle 10 Sekunden
|
||||
- Sehr aggressiv — sollte 5 Minuten sein
|
||||
- **Datei:** `backend/src/server.js` Zeile 274-290
|
||||
|
||||
### 10. CORS in Dev auf `*`
|
||||
- Alle Ursprünge erlaubt — sollte auch in Dev eingeschränkt sein
|
||||
- **Datei:** `backend/src/server.js` Zeilen 46-53
|
||||
|
||||
### 11. Onboarding-PII unverschlüsselt
|
||||
- `emp_phone`, `emp_address`, `emp_private_email` in Klartext in DB
|
||||
- DSGVO-relevant bei DB-Leak
|
||||
- **Tabelle:** `onboarding_protocols`
|
||||
|
||||
### 12. Schema-Inkonsistenz Asset-Typen
|
||||
- DB-Schema kennt 4 Typen, seed.js seeded 9 Typen — CHECK-Constraint wird umgangen
|
||||
- **Datei:** `backend/src/db/seed.js`
|
||||
|
||||
### 13. Audit-Logging unvollständig
|
||||
- Password-Änderungen nicht gelogged
|
||||
- Token-Generierungen nicht gelogged
|
||||
- **Empfehlung:** Alle Auth-Events in `audit_log` schreiben
|
||||
|
||||
---
|
||||
|
||||
## 📊 Vollständige Route-Übersicht
|
||||
|
||||
### Auth Routes (`/api/auth`)
|
||||
| Methode | Pfad | Auth |
|
||||
|---|---|---|
|
||||
| POST | /login | Public (Rate-Limited) |
|
||||
| GET | /me | JWT |
|
||||
| POST | /change-password | JWT |
|
||||
| PUT | /notifications | JWT |
|
||||
| PUT | /staff-notifications | JWT |
|
||||
| GET | /microsoft | Public |
|
||||
| GET | /microsoft/callback | Public |
|
||||
| GET | /microsoft/addin | Public |
|
||||
| GET | /microsoft/addin-callback | Public |
|
||||
| GET | /microsoft/addin-result/:sessionId | Public |
|
||||
|
||||
### Monitoring Routes (`/api/monitoring`)
|
||||
| Methode | Pfad | Auth |
|
||||
|---|---|---|
|
||||
| POST | /checkin | X-Agent-Key |
|
||||
| POST | /announcements-poll | X-Agent-Key |
|
||||
| GET | /agent-script | X-Agent-Key |
|
||||
| GET | /agent-setup | X-Agent-Key (Controller) |
|
||||
| GET | /statistics | JWT + Admin |
|
||||
| GET | / | JWT + Admin |
|
||||
| GET | /:id | JWT + Admin |
|
||||
| DELETE | /:id | JWT + Admin |
|
||||
|
||||
### Patch Routes (`/api/patch`)
|
||||
| Methode | Pfad | Auth |
|
||||
|---|---|---|
|
||||
| POST | /commands/result | X-Agent-Key |
|
||||
| GET | /overview | JWT |
|
||||
| GET | /groups | JWT + Admin |
|
||||
| POST | /groups | JWT + Admin |
|
||||
| PUT | /groups/:id | JWT + Admin |
|
||||
| DELETE | /groups/:id | JWT + Admin |
|
||||
| POST | /commands/trigger | JWT + Admin |
|
||||
| POST | /commands/trigger-group | JWT + Admin |
|
||||
| GET | /commands | JWT + Admin |
|
||||
|
||||
---
|
||||
|
||||
## 📋 Datenbank-Tabellen (38 gesamt)
|
||||
|
||||
| Tabelle | Zweck |
|
||||
|---|---|
|
||||
| roles | Benutzerrollen |
|
||||
| users | Benutzerkonten |
|
||||
| fido_keys | FIDO2-Schlüssel |
|
||||
| assets | IT-Assets |
|
||||
| asset_assignments | Asset-Zuweisungen |
|
||||
| asset_inspections | Inspektionen |
|
||||
| asset_movements | Bewegungshistorie |
|
||||
| asset_types | Asset-Kategorien |
|
||||
| onboarding_protocols | Onboarding |
|
||||
| offboarding_protocols | Offboarding |
|
||||
| tickets | Support-Tickets |
|
||||
| ticket_comments | Kommentare |
|
||||
| ticket_links | Ticket-Verknüpfungen |
|
||||
| ticket_assignees | Mehrfach-Zuweisungen |
|
||||
| ticket_routing | Auto-Routing |
|
||||
| monitoring_agents | Windows Agenten |
|
||||
| network_devices | Netzwerk-Monitoring |
|
||||
| device_checks | Prüf-Ergebnisse |
|
||||
| health_history | System-Health |
|
||||
| patch_groups | Patch-Gruppen |
|
||||
| patch_policies | Patch-Richtlinien |
|
||||
| patch_agent_groups | Agent-Gruppen |
|
||||
| patch_commands | Patch-Befehle |
|
||||
| licenses | Softwarelizenzen |
|
||||
| warehouse_locations | Lagerplätze |
|
||||
| stock_thresholds | Mindestbestände |
|
||||
| purchase_orders | Bestellungen |
|
||||
| knowledge_base | Wissensdatenbank |
|
||||
| ai_knowledge | KI-Wissensdatenbank |
|
||||
| portal_guides | Portal-Guides |
|
||||
| announcements | Ankündigungen |
|
||||
| announcement_acks | Bestätigungen |
|
||||
| external_alerts | Externe Warnungen |
|
||||
| teams_channels | Teams-Kanäle |
|
||||
| audit_log | Audit-Protokoll |
|
||||
| unifi_config | Unifi-Konfiguration |
|
||||
| unifi_devices | Unifi-Geräte |
|
||||
| it_topics | IT-Übersicht |
|
||||
|
||||
---
|
||||
|
||||
## 📋 Priorisierte To-Do-Liste
|
||||
|
||||
| Prio | Aufwand | Was | Datei |
|
||||
|---|---|---|---|
|
||||
| 1 | 5 Min | Code-Fallback `'1.2.4'` → `'2.0.0'` | monitoringAgent.controller.js |
|
||||
| 2 | 30 Min | Rate-Limiting auf public Ticket-Erstellung | server.js |
|
||||
| 3 | 30 Min | Pagination auf /api/users, /api/assets, /api/tickets | controllers |
|
||||
| 4 | 1h | Graceful Shutdown für SQLite | server.js |
|
||||
| 5 | 2h | E-Mail Retry-Logic | ticket.controller.js |
|
||||
| 6 | 2h | SSE Token aus Query absichern | routes |
|
||||
| 7 | 1 Tag | Strukturiertes Logging einführen | server.js + alle |
|
||||
| 8 | 1 Tag | PII-Felder verschlüsseln (DSGVO) | onboarding_protocols |
|
||||
|
||||
---
|
||||
|
||||
## Live-System Stand (07.05.2026)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Health letzten 10 Tage | ✅ alle OK |
|
||||
| Frontend erreichbar | ✅ |
|
||||
| API erreichbar | ✅ |
|
||||
| Agent v2.0.0 | ✅ IT-NB-02, TBO-NB-02, FAM102223 |
|
||||
| Geräte mit altem Agent | ⚠️ ~41 Geräte noch v1.x |
|
||||
| CT 110 (AI) | ✅ nach fsck repariert |
|
||||
| CT 102, 103, 111 | ✅ alle healthy |
|
||||
|
||||
---
|
||||
*Nächster Schritt: Priorisierung mit Simon besprechen, dann schrittweise abarbeiten.*
|
||||
1074
Tickets-Demo.html
1074
Tickets-Demo.html
File diff suppressed because it is too large
Load Diff
@@ -1,79 +0,0 @@
|
||||
# ZELO 5.3 — Installationsanleitung
|
||||
**Winkel Ruf-Leitsysteme | Version 5.3.0.28**
|
||||
|
||||
---
|
||||
|
||||
## Voraussetzungen
|
||||
- Windows 10/11 (x64)
|
||||
- SQL Server 2019 Express muss bereits installiert sein (siehe ZELO 6.0 Anleitung, Phase 1)
|
||||
- Setup-Datei: `SetupZeloConfig.exe`
|
||||
- Pfad: `C:\Winkel Soft\02 Zelo (5.3_5.4_6.0)\01 ZELO5.3\Winkel_Soft\01 ZELO Setup\zeloSetup 5.3.0.28\`
|
||||
- Installer: `SetupZeloConfig.exe` (1.553 KB)
|
||||
- Administratorrechte auf dem Zielrechner
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
### Schritt 1 — Startmenü
|
||||
- `SetupZeloConfig.exe` starten
|
||||
- Zwei Optionen erscheinen:
|
||||
- **"zelo Config"** → auswählen
|
||||
- "Microsoft® SQL Server® 2019 Express" → **nicht auswählen** (bereits installiert)
|
||||
|
||||
### Schritt 2 — AGB akzeptieren
|
||||
- Checkbox **"Ich habe die AGB der Winkel GmbH gelesen und akzeptiere diese"** aktivieren ✅
|
||||
- Klick: **Weiter**
|
||||
|
||||
### Schritt 3 — Eingaben zur Installation
|
||||
- Funktion: **Server** auswählen (USB/CAN-Adapter ist an diesem PC angeschlossen)
|
||||
- SQL-Servername: **`localhost\ZELO`**
|
||||
- Benutzer: **`sa`** (manuell eingeben)
|
||||
- Passwort: **`Cered@ZeloFL24`** (manuell eingeben)
|
||||
- Zielverzeichnis: `C:\zeloConfig` (Standard lassen)
|
||||
- Klick: **Verbindungstest** — grünes Symbol muss erscheinen ✅
|
||||
- Klick: **Weiter**
|
||||
|
||||
> ⚠️ Alle Felder müssen manuell befüllt werden — werden nicht automatisch übernommen.
|
||||
|
||||
### Schritt 4 — Komponenten auswählen
|
||||
- Alle drei Komponenten aktivieren:
|
||||
- ✅ **zelo Config, Benutzeroberfläche**
|
||||
- ✅ **zelo Server, Windows Dienst zur Anbindung des zelo Systems über den CAN-Adapter**
|
||||
- ✅ **zelo DB, Beispieldatenbank**
|
||||
- Klick: **Weiter**
|
||||
|
||||
### Schritt 5 — Zusammenfassung prüfen
|
||||
- Angaben kontrollieren:
|
||||
- Art der Lizenz: `zelo Server`
|
||||
- SQLServer: `localhost\ZELO`
|
||||
- Benutzer: `sa`
|
||||
- Zielverzeichnis: `C:\zeloConfig`
|
||||
- Klick: **"zelo Config jetzt installieren"**
|
||||
|
||||
### Schritt 6 — Installation läuft
|
||||
- Fortschrittsbalken läuft durch
|
||||
- Warten bis "Installation ist abgeschlossen..." erscheint
|
||||
- Klick: **Weiter**
|
||||
|
||||
### Schritt 7 — Abschließen
|
||||
- Haken so lassen:
|
||||
- ✅ Desktopverknüpfung erstellen
|
||||
- ✅ Eintrag im Startmenü hinzufügen
|
||||
- ☐ Bei Computerstart automatisch starten (leer lassen)
|
||||
- Klick: **Fertigstellen**
|
||||
|
||||
---
|
||||
|
||||
## Zugangsdaten
|
||||
|
||||
| Parameter | Wert |
|
||||
|---|---|
|
||||
| SQL-Instanz | `localhost\ZELO` |
|
||||
| SA-Benutzer | `sa` |
|
||||
| SA-Passwort | `Cered@ZeloFL24` |
|
||||
| Installationsverzeichnis | `C:\zeloConfig` |
|
||||
|
||||
---
|
||||
|
||||
*Erstellt: Mai 2026 | Cereda Systems GmbH*
|
||||
@@ -1,163 +0,0 @@
|
||||
# ZELO 6.0 — Installationsanleitung
|
||||
**Winkel Ruf-Leitsysteme | Version 6.0.0.62**
|
||||
|
||||
---
|
||||
|
||||
## Voraussetzungen
|
||||
- Windows 10/11 (x64)
|
||||
- Setup-Datei: `Setup_ZELO.exe` (V6.0.0.62)
|
||||
- Administratorrechte auf dem Zielrechner
|
||||
|
||||
---
|
||||
|
||||
## Phase 0: Vorbereitung — PowerShell (vor dem Setup!)
|
||||
|
||||
> ⚠️ Dieser Schritt muss **vor** dem Start des Setups ausgeführt werden, da Windows Dateien von Netzlaufwerken/USB blockiert. Das Setup öffnet sich danach automatisch.
|
||||
|
||||
**PowerShell als Administrator** öffnen und folgenden Befehl ausführen:
|
||||
|
||||
```powershell
|
||||
# ZELO Setup Installer
|
||||
# Dateien entsperren und Setup starten
|
||||
|
||||
$setupPath = 'C:\Winkel Soft\02 Zelo (5.3_5.4_6.0)\03 ZELO6.0 (inkl 5.4)\Winkel_Soft\01 ZELO Setup\ZeloSetup 6.0.0.62'
|
||||
|
||||
Write-Host "Entsperre alle Dateien..." -ForegroundColor Cyan
|
||||
Get-ChildItem -Path $setupPath -Recurse | Unblock-File
|
||||
|
||||
Write-Host "Starte Setup..." -ForegroundColor Green
|
||||
Start-Process -FilePath "$setupPath\Setup_ZELO.exe" -Verb RunAs -Wait
|
||||
|
||||
Write-Host "Fertig!" -ForegroundColor Green
|
||||
```
|
||||
|
||||
Das Script:
|
||||
1. Entsperrt alle Dateien im Setup-Ordner (`Unblock-File`)
|
||||
2. Startet `Setup_ZELO.exe` automatisch mit Administratorrechten
|
||||
3. Wartet bis das Setup abgeschlossen ist
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: SQL Server 2019 installieren
|
||||
|
||||
### Schritt 1 — System Check
|
||||
- `Setup_ZELO.exe` als Administrator starten
|
||||
- Installationstyp: **SQL Server** auswählen
|
||||
- Modus: **Einzelplatzinstallation**
|
||||
- Klick: **Weiter**
|
||||
|
||||
### Schritt 2 — Parameter
|
||||
- Installationsverzeichnis: `C:\Program Files\ZELO Config` (Standard)
|
||||
- SQL-Instanzname: `ZELO`
|
||||
- Anmeldename: `sa`
|
||||
- Passwort: `Olez_2013` *(wird später in Schritt 10 auf `Cered@ZeloFL24` geändert)*
|
||||
- Klick: **Installieren**
|
||||
|
||||
> SQL Server 2019 Express wird jetzt im Hintergrund heruntergeladen und vorbereitet.
|
||||
|
||||
### Schritt 3 — SQL Server 2019 Installationsassistent
|
||||
Der SQL Server 2019 Setup-Assistent öffnet sich automatisch.
|
||||
|
||||
### Schritt 4 — SQL Lizenzbedingungen
|
||||
- Checkbox **"Ich akzeptiere die Lizenzbedingungen"** aktivieren
|
||||
- Klick: **Weiter**
|
||||
|
||||
### Schritt 5 — Setupdateien installieren
|
||||
- Warten bis der Fortschrittsbalken abgeschlossen ist und "Weiter" aktiv wird
|
||||
- Klick: **Weiter**
|
||||
|
||||
### Schritt 6 — Installationsregeln
|
||||
- Firewall-Warnung ist **normal** und kann ignoriert werden
|
||||
- Klick: **Weiter**
|
||||
|
||||
### Schritt 7 — Funktionsauswahl
|
||||
- Standard-Auswahl beibehalten: **Datenbank-Engine-Dienste** + **SQL Server-Replikation**
|
||||
- Klick: **Weiter**
|
||||
|
||||
### Schritt 8 — Instanzkonfiguration
|
||||
- Named Instance: **ZELO** (vorausgefüllt, nicht ändern)
|
||||
- Instanz-ID: **ZELO** (vorausgefüllt, nicht ändern)
|
||||
- Klick: **Weiter**
|
||||
|
||||
### Schritt 9 — Serverkonfiguration
|
||||
| Dienst | Konto | Starttyp |
|
||||
|---|---|---|
|
||||
| SQL Server-Datenbank-Engine | `NT AUTHORITY\SYSTEM` | Automatisch |
|
||||
| SQL Server-Browser | `NT AUTHORITY\LOCAL SERVICE` | Automatisch |
|
||||
|
||||
- Klick: **Weiter**
|
||||
|
||||
### Schritt 10 — Datenbank-Engine-Konfiguration
|
||||
- Tab: **Serverkonfiguration**
|
||||
- Authentifizierungsmodus: **Gemischter Modus** (SQL Server + Windows)
|
||||
- SA-Kennwort: **`Cered@ZeloFL24`** *(beide Felder — Standardpasswort funktioniert nicht!)*
|
||||
- SQL Server-Administratoren:
|
||||
- **"Aktuellen Benutzer hinzufügen"** klicken
|
||||
- ⚠️ Prüfen ob der **richtige User** eingetragen ist (z.B. `WINKEL\Bill`)
|
||||
- Falls ein Admin-Account steht: **"Entfernen"** → **"Hinzufügen..."** → korrekten User manuell suchen
|
||||
- Klick: **Weiter**
|
||||
|
||||
### Schritt 11 — Installationsstatus
|
||||
- Warten bis die Installation abgeschlossen ist (Fortschrittsbalken)
|
||||
- "Weiter" wird aktiv sobald fertig
|
||||
|
||||
### Schritt 12 — Abgeschlossen
|
||||
- Alle Komponenten zeigen Status **"Erfolgreich"** ✅
|
||||
- Details: "Installation erfolgreich"
|
||||
- Klick: **Schließen**
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: ZELO Software installieren
|
||||
|
||||
### Schritt 13 — ZELO Setup neu starten & Komponenten auswählen
|
||||
- `Setup_ZELO.exe` erneut starten
|
||||
- Im System Check erscheinen nun alle Komponenten
|
||||
- Die oberen sind bereits installiert (.NET, Ixxat, SQL Server) — **nicht anfassen**
|
||||
- Die **drei unteren** aktivieren (Haken setzen):
|
||||
- ✅ **ZELO Server 6.x** (6.0.0.62)
|
||||
- ✅ **ZELO Client** (6.0.0.62)
|
||||
- ✅ **ZELO Server 5.4** (5.4.1.4)
|
||||
- Klick: **Weiter**
|
||||
|
||||
### Schritt 14 — Parameter (SQL-Verbindung)
|
||||
- Installationsverzeichnis: `C:\Program Files\ZELO Config` (Standard lassen)
|
||||
- SQL-Instanz wird automatisch erkannt: `COMPUTERNAME\ZELO`
|
||||
- Anmeldename: **`sa`**
|
||||
- Passwort: **`Cered@ZeloFL24`** eintragen
|
||||
- Klick: **"Verbindungstest"** — muss erfolgreich sein ✅
|
||||
- Klick: **Installieren**
|
||||
|
||||
### Schritt 15 — Installations-Status (Abgeschlossen)
|
||||
- Beide Haken können so bleiben:
|
||||
- ✅ Verknüpfung auf dem Desktop erstellen
|
||||
- ✅ Protokolldatei der Installation speichern (Empfohlen)
|
||||
- Klick: **Fertigstellen**
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Testen
|
||||
|
||||
### Schritt 16 — Desktop-Verknüpfung
|
||||
- Nach der Installation erscheint auf dem Desktop das Icon **"ZELO Config"**
|
||||
|
||||
### Schritt 17 — ZELO Config starten
|
||||
- Doppelklick auf das Desktop-Icon "ZELO Config"
|
||||
- Ladebildschirm erscheint — Version **6.0.0.62** wird angezeigt
|
||||
- Warten bis vollständig geladen
|
||||
- ✅ Installation erfolgreich wenn die Anwendung startet
|
||||
|
||||
---
|
||||
|
||||
## Zusammenfassung wichtiger Zugangsdaten
|
||||
|
||||
| Parameter | Wert |
|
||||
|---|---|
|
||||
| SQL-Instanz | `COMPUTERNAME\ZELO` |
|
||||
| SA-Benutzer | `sa` |
|
||||
| SA-Passwort | `Cered@ZeloFL24` |
|
||||
| Installationsverzeichnis | `C:\Program Files\ZELO Config` |
|
||||
|
||||
---
|
||||
|
||||
*Erstellt: Mai 2026 | Cereda Systems GmbH*
|
||||
1
_ul
1
_ul
@@ -1 +0,0 @@
|
||||
mkdir: cannot create directory ‘C:\\gradle-home\\it_nexus_build’: File exists
|
||||
@@ -6,7 +6,7 @@ namespace ITNexusAgent;
|
||||
|
||||
public class AgentWorker
|
||||
{
|
||||
private const string Version = "2.7.0";
|
||||
private const string Version = "2.9.0";
|
||||
private const string DataDir = @"C:\ProgramData\IT Nexus Agent";
|
||||
private const string ConfigPath = @"C:\ProgramData\IT Nexus Agent\config.json";
|
||||
private const string StatusPath = @"C:\ProgramData\IT Nexus Agent\status.json";
|
||||
@@ -40,12 +40,25 @@ public class AgentWorker
|
||||
|
||||
Log($"Agent v{Version} gestartet");
|
||||
|
||||
// Security-Migration: geteilten Bootstrap-Key gegen individuellen Per-Device-Key tauschen.
|
||||
// Idempotent (Server liefert bestehenden Key erneut) — daher bei jedem Start sicher aufrufbar.
|
||||
var hostname = SystemInfoService.GetHostname();
|
||||
var enrolledKey = await _api.EnrollAsync(hostname);
|
||||
var configWasUnencrypted = !File.ReadAllText(ConfigPath).Contains("dpapi:");
|
||||
if (!string.IsNullOrEmpty(enrolledKey) && (enrolledKey != _config.AgentKey || configWasUnencrypted))
|
||||
{
|
||||
_config.AgentKey = enrolledKey;
|
||||
_config.Save(ConfigPath); // schreibt agent_key jetzt DPAPI-verschlüsselt statt im Klartext
|
||||
_api.UpdateKey(enrolledKey);
|
||||
Log("ENROLL: Per-Device-Key erhalten und gespeichert");
|
||||
}
|
||||
|
||||
// WebSocket Shell-Service im Hintergrund starten
|
||||
var shellService = new ShellService(_config.ServerUrl, _config.AgentKey, SystemInfoService.GetHostname());
|
||||
var shellService = new ShellService(_config.ServerUrl, _config.AgentKey, hostname);
|
||||
_ = shellService.RunAsync(_ct);
|
||||
|
||||
// WebRTC Remote Desktop Service im Hintergrund starten
|
||||
var rtcService = new RtcService(_config.ServerUrl, _config.AgentKey, SystemInfoService.GetHostname());
|
||||
var rtcService = new RtcService(_config.ServerUrl, _config.AgentKey, hostname);
|
||||
_ = rtcService.RunAsync(_ct);
|
||||
|
||||
while (!_ct.IsCancellationRequested)
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<AssemblyName>IT-Nexus-Agent</AssemblyName>
|
||||
<RootNamespace>ITNexusAgent</RootNamespace>
|
||||
<Version>2.7.0</Version>
|
||||
<AssemblyVersion>2.7.0.0</AssemblyVersion>
|
||||
<Version>2.9.0</Version>
|
||||
<AssemblyVersion>2.9.0.0</AssemblyVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<AllowUnsafeBlocks>false</AllowUnsafeBlocks>
|
||||
@@ -31,6 +31,7 @@
|
||||
<PackageReference Include="System.Drawing.Common" Version="8.0.0" />
|
||||
<PackageReference Include="System.Management" Version="8.0.0" />
|
||||
<PackageReference Include="System.ServiceProcess.ServiceController" Version="8.0.0" />
|
||||
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,20 +1,69 @@
|
||||
using Newtonsoft.Json;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace ITNexusAgent.Models;
|
||||
|
||||
public class AgentConfig
|
||||
{
|
||||
[JsonProperty("server_url")]
|
||||
private const string ProtectedPrefix = "dpapi:";
|
||||
|
||||
[JsonIgnore]
|
||||
public string ServerUrl { get; set; } = "";
|
||||
|
||||
[JsonProperty("agent_key")]
|
||||
// Im Speicher immer Klartext — nur auf der Platte (config.json) liegt der verschlüsselte Wert.
|
||||
[JsonIgnore]
|
||||
public string AgentKey { get; set; } = "";
|
||||
|
||||
private class RawConfig
|
||||
{
|
||||
[JsonProperty("server_url")] public string ServerUrl { get; set; } = "";
|
||||
[JsonProperty("agent_key")] public string AgentKey { get; set; } = "";
|
||||
}
|
||||
|
||||
public static AgentConfig Load(string path)
|
||||
{
|
||||
var json = File.ReadAllText(path);
|
||||
return JsonConvert.DeserializeObject<AgentConfig>(json)
|
||||
var raw = JsonConvert.DeserializeObject<RawConfig>(json)
|
||||
?? throw new Exception("Ungültige config.json");
|
||||
|
||||
return new AgentConfig
|
||||
{
|
||||
ServerUrl = raw.ServerUrl,
|
||||
AgentKey = Unprotect(raw.AgentKey),
|
||||
};
|
||||
}
|
||||
|
||||
// Verschlüsselt den Key per Windows DPAPI (LocalMachine-Scope) bevor er auf die Platte geschrieben
|
||||
// wird — ein Klartext-Auslesen von config.json bringt einem Angreifer dann nichts mehr, da der Wert
|
||||
// nur vom SYSTEM-Konto auf genau diesem Rechner wieder entschlüsselt werden kann.
|
||||
public void Save(string path)
|
||||
{
|
||||
var raw = new RawConfig { ServerUrl = ServerUrl, AgentKey = Protect(AgentKey) };
|
||||
File.WriteAllText(path, JsonConvert.SerializeObject(raw, Formatting.Indented));
|
||||
}
|
||||
|
||||
private static string Protect(string plaintext)
|
||||
{
|
||||
if (string.IsNullOrEmpty(plaintext)) return plaintext;
|
||||
var bytes = ProtectedData.Protect(Encoding.UTF8.GetBytes(plaintext), null, DataProtectionScope.LocalMachine);
|
||||
return ProtectedPrefix + Convert.ToBase64String(bytes);
|
||||
}
|
||||
|
||||
// Erkennt das alte Klartext-Format (z.B. frisch aus dem Installer-Template) und lässt es unverändert
|
||||
// durch — wird beim nächsten Save() automatisch verschlüsselt persistiert.
|
||||
private static string Unprotect(string stored)
|
||||
{
|
||||
if (string.IsNullOrEmpty(stored) || !stored.StartsWith(ProtectedPrefix)) return stored;
|
||||
try
|
||||
{
|
||||
var bytes = ProtectedData.Unprotect(Convert.FromBase64String(stored[ProtectedPrefix.Length..]), null, DataProtectionScope.LocalMachine);
|
||||
return Encoding.UTF8.GetString(bytes);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return stored; // Korrupt/falsche Maschine → unverändert zurückgeben statt Crash
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,25 @@ public class ApiService(string serverUrl, string agentKey)
|
||||
{
|
||||
private readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(30) };
|
||||
private readonly string _baseUrl = serverUrl.TrimEnd('/');
|
||||
private readonly string _agentKey = agentKey;
|
||||
private string _agentKey = agentKey;
|
||||
|
||||
// Nach erfolgreichem Enrollment wird der geteilte Bootstrap-Key durch den individuellen
|
||||
// Per-Device-Key ersetzt — alle nachfolgenden Requests dieser Instanz nutzen ab dann den neuen Key.
|
||||
public void UpdateKey(string newKey) => _agentKey = newKey;
|
||||
|
||||
public async Task<string?> EnrollAsync(string hostname)
|
||||
{
|
||||
try
|
||||
{
|
||||
var req = BuildRequest(HttpMethod.Post, "/api/monitoring/enroll", new { hostname });
|
||||
var resp = await _http.SendAsync(req);
|
||||
if (!resp.IsSuccessStatusCode) return null;
|
||||
var body = await resp.Content.ReadAsStringAsync();
|
||||
var result = JsonConvert.DeserializeAnonymousType(body, new { status = "", data = new { agent_key = "" } });
|
||||
return result?.data?.agent_key;
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
private HttpRequestMessage BuildRequest(HttpMethod method, string path, object? body = null)
|
||||
{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#define MyAppName "IT Nexus Agent"
|
||||
#define MyAppVersion "2.7.0"
|
||||
#define MyAppVersion "2.9.0"
|
||||
#define MyAppPublisher "Cereda Systems GmbH"
|
||||
#define MyAppURL "https://it-nexus.cereda-systems.de"
|
||||
#define MyAppExeName "IT-Nexus-Agent.exe"
|
||||
|
||||
@@ -1,524 +0,0 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
||||
<meta http-equiv="x-ua-compatible" content="ie=11">
|
||||
<title>IT Nexus Agent Setup</title>
|
||||
<HTA:APPLICATION
|
||||
APPLICATIONNAME="IT Nexus Agent Setup"
|
||||
BORDER="thin"
|
||||
BORDERSTYLE="normal"
|
||||
CAPTION="yes"
|
||||
CONTEXTMENU="no"
|
||||
MAXIMIZEBUTTON="no"
|
||||
MINIMIZEBUTTON="yes"
|
||||
NAVIGABLE="no"
|
||||
SCROLL="no"
|
||||
SELECTION="no"
|
||||
SHOWINTASKBAR="yes"
|
||||
SINGLEINSTANCE="yes"
|
||||
SYSMENU="yes"
|
||||
WINDOWSTATE="normal"
|
||||
/>
|
||||
<style>
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, sans-serif;
|
||||
background: #1e1e2e;
|
||||
color: #cdd6f4;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar {
|
||||
position: absolute;
|
||||
left: 0; top: 0; bottom: 0;
|
||||
width: 220px;
|
||||
background: #181825;
|
||||
border-right: 1px solid #313244;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 28px 0 20px;
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
padding: 0 20px 28px;
|
||||
border-bottom: 1px solid #313244;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.sidebar-logo-title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #cdd6f4;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.sidebar-logo-sub {
|
||||
font-size: 14px;
|
||||
color: #6c7086;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 13px 20px;
|
||||
font-size: 16px;
|
||||
color: #6c7086;
|
||||
cursor: default;
|
||||
}
|
||||
.nav-item.done { color: #a6e3a1; }
|
||||
.nav-item.active { color: #89b4fa; font-weight: 600; background: rgba(137,180,250,0.08); border-right: 3px solid #89b4fa; }
|
||||
|
||||
.nav-num {
|
||||
width: 28px; height: 28px;
|
||||
border-radius: 50%;
|
||||
font-size: 13px; font-weight: 700;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
flex-shrink: 0;
|
||||
background: #313244;
|
||||
color: #6c7086;
|
||||
}
|
||||
.nav-item.done .nav-num { background: #a6e3a1; color: #1e1e2e; }
|
||||
.nav-item.active .nav-num { background: #89b4fa; color: #1e1e2e; }
|
||||
|
||||
/* Main */
|
||||
.main {
|
||||
position: absolute;
|
||||
left: 220px; top: 0; right: 0; bottom: 60px;
|
||||
padding: 36px 36px 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.step { display: none; }
|
||||
.step.active { display: block; }
|
||||
|
||||
.step-title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #cdd6f4;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.step-subtitle {
|
||||
font-size: 16px;
|
||||
color: #6c7086;
|
||||
margin-bottom: 28px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Feature list */
|
||||
.feature-list { list-style: none; }
|
||||
.feature-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid #313244;
|
||||
font-size: 17px;
|
||||
}
|
||||
.feature-item:last-child { border-bottom: none; }
|
||||
.feature-dot {
|
||||
width: 10px; height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #89b4fa;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Fields */
|
||||
.field { margin-bottom: 14px; }
|
||||
.field label {
|
||||
font-size: 14px;
|
||||
color: #6c7086;
|
||||
display: block;
|
||||
margin-bottom: 7px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.field input {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
background: #181825;
|
||||
border: 1px solid #313244;
|
||||
border-radius: 6px;
|
||||
color: #cdd6f4;
|
||||
font-size: 16px;
|
||||
font-family: 'Segoe UI', sans-serif;
|
||||
transition: border-color .15s;
|
||||
}
|
||||
.field input:focus { outline: none; border-color: #89b4fa; }
|
||||
.field input[readonly] { color: #6c7086; }
|
||||
|
||||
/* Summary */
|
||||
.summary-box {
|
||||
background: #181825;
|
||||
border: 1px solid #313244;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.summary-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 14px 18px;
|
||||
font-size: 15px;
|
||||
border-bottom: 1px solid #313244;
|
||||
}
|
||||
.summary-row:last-child { border-bottom: none; }
|
||||
.summary-key { color: #6c7086; }
|
||||
.summary-val { color: #89b4fa; font-weight: 600; font-size: 12px; max-width: 200px; text-align: right; }
|
||||
|
||||
.warn-box {
|
||||
background: rgba(250,179,135,0.08);
|
||||
border: 1px solid rgba(250,179,135,0.2);
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
font-size: 12px;
|
||||
color: #fab387;
|
||||
}
|
||||
|
||||
/* Log */
|
||||
#log {
|
||||
background: #11111b;
|
||||
border: 1px solid #313244;
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
height: 50%;
|
||||
overflow-y: auto;
|
||||
font-family: 'Consolas', 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.log-ok { color: #a6e3a1; }
|
||||
.log-err { color: #f38ba8; }
|
||||
.log-info { color: #6c7086; }
|
||||
.log-warn { color: #f9e2af; }
|
||||
|
||||
/* Progress */
|
||||
.progress-label {
|
||||
font-size: 12px;
|
||||
color: #6c7086;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.progress-wrap {
|
||||
background: #313244;
|
||||
border-radius: 4px;
|
||||
height: 6px;
|
||||
margin-bottom: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(90deg, #89b4fa, #b4befe);
|
||||
width: 0%;
|
||||
transition: width .35s ease;
|
||||
}
|
||||
|
||||
/* Success */
|
||||
.success-wrap { text-align: center; padding-top: 10px; }
|
||||
.success-check {
|
||||
width: 64px; height: 64px;
|
||||
border-radius: 50%;
|
||||
background: rgba(166,227,161,0.12);
|
||||
border: 2px solid #a6e3a1;
|
||||
margin: 0 auto 16px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 28px;
|
||||
color: #a6e3a1;
|
||||
}
|
||||
.success-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #a6e3a1;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.success-sub {
|
||||
font-size: 13px;
|
||||
color: #6c7086;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.success-hint {
|
||||
margin-top: 14px;
|
||||
background: rgba(137,180,250,0.08);
|
||||
border: 1px solid rgba(137,180,250,0.2);
|
||||
border-radius: 6px;
|
||||
padding: 10px 14px;
|
||||
font-size: 12px;
|
||||
color: #89b4fa;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
position: absolute;
|
||||
left: 220px; right: 0; bottom: 0;
|
||||
height: 60px;
|
||||
border-top: 1px solid #313244;
|
||||
background: #181825;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 0 24px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 11px 28px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
font-size: 15px;
|
||||
font-family: 'Segoe UI', sans-serif;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all .15s;
|
||||
min-width: 110px;
|
||||
}
|
||||
.btn:hover { filter: brightness(1.1); }
|
||||
.btn:disabled { opacity: .35; cursor: not-allowed; }
|
||||
.btn-ghost { background: transparent; color: #6c7086; border: 1px solid #313244; }
|
||||
.btn-primary { background: #89b4fa; color: #1e1e2e; }
|
||||
.btn-success { background: #a6e3a1; color: #1e1e2e; }
|
||||
.btn-close { background: #585b70; color: #cdd6f4; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-logo">
|
||||
<div class="sidebar-logo-title">IT Nexus Agent</div>
|
||||
<div class="sidebar-logo-sub">Setup 1.0.0</div>
|
||||
</div>
|
||||
<div class="nav-item active" id="nav1"><div class="nav-num" id="nn1">1</div> Willkommen</div>
|
||||
<div class="nav-item" id="nav2"><div class="nav-num" id="nn2">2</div> Konfiguration</div>
|
||||
<div class="nav-item" id="nav3"><div class="nav-num" id="nn3">3</div> Zusammenfassung</div>
|
||||
<div class="nav-item" id="nav4"><div class="nav-num" id="nn4">4</div> Installation</div>
|
||||
<div class="nav-item" id="nav5"><div class="nav-num" id="nn5">5</div> Abgeschlossen</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="main">
|
||||
|
||||
<!-- Step 1 -->
|
||||
<div class="step active" id="step1">
|
||||
<div class="step-title">Willkommen</div>
|
||||
<div class="step-subtitle">Der IT Nexus Agent wird als Windows-Dienst installiert und sendet alle 5 Minuten Systemdaten an das IT Nexus Dashboard.</div>
|
||||
<ul class="feature-list">
|
||||
<li class="feature-item"><span class="feature-dot"></span> CPU, RAM & Festplatten-Auslastung</li>
|
||||
<li class="feature-item"><span class="feature-dot"></span> Installierte Software</li>
|
||||
<li class="feature-item"><span class="feature-dot"></span> Windows Update Status</li>
|
||||
<li class="feature-item"><span class="feature-dot"></span> Angemeldeter Benutzer & Uptime</li>
|
||||
<li class="feature-item"><span class="feature-dot"></span> Netzwerk & Systeminformationen</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Step 2 -->
|
||||
<div class="step" id="step2">
|
||||
<div class="step-title">Konfiguration</div>
|
||||
<div class="step-subtitle">Serververbindung konfigurieren.</div>
|
||||
<div class="field">
|
||||
<label>Server URL</label>
|
||||
<input type="text" id="serverUrl" value="https://it-nexus.cereda-systems.de">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Agent API Key</label>
|
||||
<input type="text" id="agentKey" value="cereda-agent-2024-secure-key">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Installationsverzeichnis</label>
|
||||
<input type="text" id="installDir" value="C:\ProgramData\IT Nexus Agent" readonly>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3 -->
|
||||
<div class="step" id="step3">
|
||||
<div class="step-title">Bereit zur Installation</div>
|
||||
<div class="step-subtitle">Bitte alles pruefen, dann auf Installieren klicken.</div>
|
||||
<div class="summary-box">
|
||||
<div class="summary-row"><span class="summary-key">Verzeichnis</span><span class="summary-val" id="sum-dir"></span></div>
|
||||
<div class="summary-row"><span class="summary-key">Server</span><span class="summary-val" id="sum-url"></span></div>
|
||||
<div class="summary-row"><span class="summary-key">Scheduled Task</span><span class="summary-val">Alle 5 Minuten (SYSTEM)</span></div>
|
||||
<div class="summary-row"><span class="summary-key">Agent Version</span><span class="summary-val">1.0.0</span></div>
|
||||
</div>
|
||||
<div class="warn-box">Hinweis: Es erscheint eine UAC-Abfrage fuer Administrator-Rechte.</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 4 -->
|
||||
<div class="step" id="step4">
|
||||
<div class="step-title">Installation</div>
|
||||
<div class="progress-label" id="progLabel">Vorbereitung...</div>
|
||||
<div class="progress-wrap"><div class="progress-fill" id="progBar"></div></div>
|
||||
<div id="log"></div>
|
||||
</div>
|
||||
|
||||
<!-- Step 5 -->
|
||||
<div class="step" id="step5">
|
||||
<div class="success-wrap">
|
||||
<div class="success-check">✓</div>
|
||||
<div class="success-title">Installation abgeschlossen</div>
|
||||
<div class="success-sub">Der IT Nexus Agent ist installiert und aktiv.<br>Er sendet alle 5 Minuten Daten an das Dashboard.</div>
|
||||
<div class="success-hint">Sichtbar unter: IT Nexus → Administration → Agent Monitoring</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="footer">
|
||||
<button class="btn btn-ghost" id="btnBack" onclick="prevStep()" style="display:none">Zurueck</button>
|
||||
<button class="btn btn-ghost" id="btnCancel" onclick="window.close()">Abbrechen</button>
|
||||
<button class="btn btn-primary" id="btnNext" onclick="nextStep()">Weiter</button>
|
||||
</div>
|
||||
|
||||
<script language="JScript">
|
||||
var step = 1;
|
||||
var shell = new ActiveXObject("WScript.Shell");
|
||||
var fso = new ActiveXObject("Scripting.FileSystemObject");
|
||||
|
||||
// Groesse anhand Bildschirmaufloesung berechnen (60% Breite, 70% Hoehe, min/max begrenzt)
|
||||
var w = Math.min(Math.max(Math.round(screen.availWidth * 0.60), 700), 1100);
|
||||
var h = Math.min(Math.max(Math.round(screen.availHeight * 0.70), 560), 820);
|
||||
window.resizeTo(w, h);
|
||||
window.moveTo((screen.availWidth-w)/2, (screen.availHeight-h)/2);
|
||||
|
||||
// Schriftgroesse skalieren
|
||||
var scale = w / 780;
|
||||
document.body.style.fontSize = Math.round(13 * scale) + "px";
|
||||
|
||||
function updateNav() {
|
||||
for (var i=1;i<=5;i++) {
|
||||
var n = document.getElementById("nav"+i);
|
||||
var nn = document.getElementById("nn"+i);
|
||||
if (i < step) { n.className="nav-item done"; nn.innerHTML="✓"; }
|
||||
else if (i === step) { n.className="nav-item active"; nn.innerHTML=i; }
|
||||
else { n.className="nav-item"; nn.innerHTML=i; }
|
||||
}
|
||||
}
|
||||
|
||||
function showStep(n) {
|
||||
for (var i=1;i<=5;i++) {
|
||||
var el=document.getElementById("step"+i);
|
||||
if(el) el.className=(i===n)?"step active":"step";
|
||||
}
|
||||
step=n;
|
||||
updateNav();
|
||||
|
||||
var btnBack = document.getElementById("btnBack");
|
||||
var btnCancel = document.getElementById("btnCancel");
|
||||
var btnNext = document.getElementById("btnNext");
|
||||
|
||||
btnBack.style.display = (n>1 && n<4) ? "" : "none";
|
||||
btnCancel.style.display = (n>=4) ? "none" : "";
|
||||
|
||||
if (n===3) {
|
||||
document.getElementById("sum-dir").innerText = document.getElementById("installDir").value;
|
||||
document.getElementById("sum-url").innerText = document.getElementById("serverUrl").value;
|
||||
btnNext.className = "btn btn-success";
|
||||
btnNext.innerText = "Installieren";
|
||||
} else if (n===4) {
|
||||
btnNext.style.display="none";
|
||||
startInstall();
|
||||
} else if (n===5) {
|
||||
btnNext.className="btn btn-close";
|
||||
btnNext.innerText="Schliessen";
|
||||
btnNext.style.display="";
|
||||
} else {
|
||||
btnNext.className="btn btn-primary";
|
||||
btnNext.innerText="Weiter";
|
||||
btnNext.style.display="";
|
||||
}
|
||||
}
|
||||
|
||||
function nextStep() { if(step===5){window.close();return;} showStep(step+1); }
|
||||
function prevStep() { if(step>1) showStep(step-1); }
|
||||
|
||||
function log(msg, type) {
|
||||
var el=document.getElementById("log");
|
||||
el.innerHTML+='<div class="'+(type||"log-info")+'">'+msg+'</div>';
|
||||
el.scrollTop=el.scrollHeight;
|
||||
}
|
||||
function setProgress(pct, label) {
|
||||
document.getElementById("progBar").style.width=pct+"%";
|
||||
if(label) document.getElementById("progLabel").innerText=label;
|
||||
}
|
||||
function sleep(ms) { var s=new Date().getTime(); while(new Date().getTime()-s<ms){} }
|
||||
function writeFile(path, content) {
|
||||
var f=fso.CreateTextFile(path,true,false);
|
||||
f.Write(content); f.Close();
|
||||
}
|
||||
|
||||
function startInstall() {
|
||||
try {
|
||||
var installDir = document.getElementById("installDir").value;
|
||||
var serverUrl = document.getElementById("serverUrl").value;
|
||||
var agentKey = document.getElementById("agentKey").value;
|
||||
|
||||
setProgress(10, "Erstelle Verzeichnis...");
|
||||
sleep(300);
|
||||
if (!fso.FolderExists(installDir)) fso.CreateFolder(installDir);
|
||||
log("Verzeichnis erstellt: " + installDir, "log-ok");
|
||||
setProgress(25, "Kopiere Agent-Dateien...");
|
||||
sleep(300);
|
||||
|
||||
var htaDir = fso.GetParentFolderName(location.href.replace("file:///","").replace(/\//g,"\\"));
|
||||
try { fso.CopyFile(htaDir+"\\it-nexus-agent.ps1", installDir+"\\it-nexus-agent.ps1", true); }
|
||||
catch(e) { log("Agent-Script wird neu erstellt...", "log-warn"); }
|
||||
log("Agent-Script kopiert", "log-ok");
|
||||
|
||||
setProgress(40, "Schreibe Konfiguration...");
|
||||
sleep(200);
|
||||
writeFile(installDir+"\\config.json", '{\n "server_url": "'+serverUrl+'",\n "agent_key": "'+agentKey+'"\n}');
|
||||
log("Konfiguration gespeichert", "log-ok");
|
||||
|
||||
setProgress(60, "Registriere Scheduled Task...");
|
||||
sleep(300);
|
||||
|
||||
var ps = [
|
||||
'$d = "'+installDir+'"',
|
||||
'$a = New-ScheduledTaskAction -Execute "powershell.exe" -Argument ("-ExecutionPolicy Bypass -NonInteractive -WindowStyle Hidden -File `\\"$d\\it-nexus-agent.ps1`\\"")',
|
||||
'$t = New-ScheduledTaskTrigger -RepetitionInterval (New-TimeSpan -Minutes 5) -Once -At (Get-Date)',
|
||||
'$s = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Minutes 2) -MultipleInstances IgnoreNew -StartWhenAvailable',
|
||||
'$p = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest',
|
||||
'Unregister-ScheduledTask -TaskName "IT Nexus Agent" -Confirm:$false -ErrorAction SilentlyContinue',
|
||||
'Register-ScheduledTask -TaskName "IT Nexus Agent" -Action $a -Trigger $t -Settings $s -Principal $p -Force | Out-Null',
|
||||
'Start-ScheduledTask -TaskName "IT Nexus Agent"'
|
||||
].join("\r\n");
|
||||
|
||||
writeFile(installDir+"\\setup-task.ps1", ps);
|
||||
|
||||
// Als Administrator ausfuehren (UAC-Prompt)
|
||||
var shellApp = new ActiveXObject("Shell.Application");
|
||||
shellApp.ShellExecute(
|
||||
"powershell.exe",
|
||||
"-ExecutionPolicy Bypass -File \"" + installDir + "\\setup-task.ps1\"",
|
||||
"",
|
||||
"runas",
|
||||
1
|
||||
);
|
||||
// Warten bis Task registriert ist
|
||||
sleep(4000);
|
||||
|
||||
setProgress(85, "Pruefe Installation...");
|
||||
sleep(600);
|
||||
log("Scheduled Task registriert (alle 5 Minuten)", "log-ok");
|
||||
log("Erster Checkin wird ausgefuehrt...", "log-info");
|
||||
sleep(400);
|
||||
log("Agent ist aktiv!", "log-ok");
|
||||
|
||||
setProgress(100, "Abgeschlossen");
|
||||
sleep(800);
|
||||
showStep(5);
|
||||
|
||||
} catch(e) {
|
||||
log("Fehler: " + e.message, "log-err");
|
||||
setProgress(0, "Fehler aufgetreten");
|
||||
var btn=document.getElementById("btnNext");
|
||||
btn.innerText="Schliessen"; btn.className="btn btn-ghost"; btn.style.display="";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,74 +0,0 @@
|
||||
# IT Nexus Announcement Watcher
|
||||
# Wird alle 15 Sekunden als SYSTEM ausgefuehrt
|
||||
# Zeigt Ankuendigungen als Desktop-Popup fuer den eingeloggten Benutzer
|
||||
|
||||
param()
|
||||
$cfg = Get-Content 'C:\ProgramData\IT Nexus Agent\config.json' -Raw | ConvertFrom-Json
|
||||
$url = $cfg.server_url.TrimEnd('/')
|
||||
$key = $cfg.agent_key
|
||||
|
||||
function Show-AnnDialog($ann) {
|
||||
$id = $ann.id
|
||||
$dlg = "C:\ProgramData\IT Nexus Agent\ann_$id.ps1"
|
||||
if (Test-Path $dlg) { return }
|
||||
|
||||
$title = ($ann.title -replace "'", "''")
|
||||
$message = ($ann.message -replace "'", "''")
|
||||
$accent = switch ($ann.type) { 'warning'{'220,50,50'} 'maintenance'{'245,158,11'} default{'99,102,241'} }
|
||||
$label = switch ($ann.type) { 'warning'{'WICHTIGE WARNUNG'} 'maintenance'{'WARTUNGSANKUENDIGUNG'} default{'INFORMATION'} }
|
||||
$ackUrl = "$url/api/announcements/$id/ack-agent"
|
||||
$n = [System.Environment]::NewLine
|
||||
|
||||
$headerColor = switch ($ann.type) { 'warning'{'220,53,69'} 'maintenance'{'255,140,0'} default{'0,120,212'} }
|
||||
$iconText = switch ($ann.type) { 'warning'{'⚠'} 'maintenance'{'🔧'} default{'ℹ'} }
|
||||
|
||||
$code = "Add-Type -AssemblyName System.Windows.Forms,System.Drawing$n"
|
||||
# Hauptfenster - weißer Hintergrund, professionell
|
||||
$code += "`$f=New-Object System.Windows.Forms.Form;`$f.Text='IT Nexus Mitteilung';`$f.Size=New-Object System.Drawing.Size(480,320);`$f.StartPosition='CenterScreen';`$f.FormBorderStyle='FixedSingle';`$f.MaximizeBox=`$false;`$f.MinimizeBox=`$false;`$f.TopMost=`$true;`$f.BackColor=[System.Drawing.Color]::White$n"
|
||||
# Farbiger Header-Balken
|
||||
$code += "`$hdr=New-Object System.Windows.Forms.Panel;`$hdr.Size=New-Object System.Drawing.Size(480,56);`$hdr.Location=New-Object System.Drawing.Point(0,0);`$hdr.BackColor=[System.Drawing.Color]::FromArgb($headerColor);`$f.Controls.Add(`$hdr)$n"
|
||||
# Logo-Text links im Header
|
||||
$code += "`$logo=New-Object System.Windows.Forms.Label;`$logo.Text='IT Nexus';`$logo.Font=New-Object System.Drawing.Font('Segoe UI',11,[System.Drawing.FontStyle]::Bold);`$logo.ForeColor=[System.Drawing.Color]::White;`$logo.Location=New-Object System.Drawing.Point(16,8);`$logo.Size=New-Object System.Drawing.Size(120,20);`$hdr.Controls.Add(`$logo)$n"
|
||||
# Typ-Label im Header
|
||||
$code += "`$lbl=New-Object System.Windows.Forms.Label;`$lbl.Text='$label';`$lbl.Font=New-Object System.Drawing.Font('Segoe UI',8);`$lbl.ForeColor=[System.Drawing.Color]::FromArgb(220,240,255);`$lbl.Location=New-Object System.Drawing.Point(16,30);`$lbl.Size=New-Object System.Drawing.Size(420,18);`$hdr.Controls.Add(`$lbl)$n"
|
||||
# Titel
|
||||
$code += "`$lt=New-Object System.Windows.Forms.Label;`$lt.Text='$title';`$lt.Font=New-Object System.Drawing.Font('Segoe UI',12,[System.Drawing.FontStyle]::Bold);`$lt.ForeColor=[System.Drawing.Color]::FromArgb(30,30,30);`$lt.Location=New-Object System.Drawing.Point(16,72);`$lt.Size=New-Object System.Drawing.Size(444,28);`$f.Controls.Add(`$lt)$n"
|
||||
# Trennlinie
|
||||
$code += "`$sep=New-Object System.Windows.Forms.Panel;`$sep.Size=New-Object System.Drawing.Size(448,1);`$sep.Location=New-Object System.Drawing.Point(16,104);`$sep.BackColor=[System.Drawing.Color]::FromArgb(220,220,220);`$f.Controls.Add(`$sep)$n"
|
||||
# Nachricht
|
||||
$code += "`$lm=New-Object System.Windows.Forms.Label;`$lm.Text='$message';`$lm.Font=New-Object System.Drawing.Font('Segoe UI',10);`$lm.ForeColor=[System.Drawing.Color]::FromArgb(60,60,60);`$lm.Location=New-Object System.Drawing.Point(16,112);`$lm.Size=New-Object System.Drawing.Size(444,120);`$lm.AutoSize=`$false;`$f.Controls.Add(`$lm)$n"
|
||||
# Button
|
||||
$code += "`$btn=New-Object System.Windows.Forms.Button;`$btn.Text='✓ Gelesen und bestätigt';`$btn.Font=New-Object System.Drawing.Font('Segoe UI',10,[System.Drawing.FontStyle]::Bold);`$btn.ForeColor=[System.Drawing.Color]::White;`$btn.BackColor=[System.Drawing.Color]::FromArgb($headerColor);`$btn.FlatStyle='Flat';`$btn.FlatAppearance.BorderSize=0;`$btn.Location=New-Object System.Drawing.Point(16,244);`$btn.Size=New-Object System.Drawing.Size(444,36);`$btn.DialogResult=[System.Windows.Forms.DialogResult]::OK;`$f.Controls.Add(`$btn);`$f.AcceptButton=`$btn$n"
|
||||
$code += "if(`$f.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK){$n"
|
||||
$code += " `$b=[System.Text.Encoding]::UTF8.GetBytes('{""hostname"":""+`$env:COMPUTERNAME+'""}')$n"
|
||||
$code += " `$r=[System.Net.WebRequest]::Create('$ackUrl')$n"
|
||||
$code += " `$r.Method='POST';`$r.ContentType='application/json';`$r.Headers.Add('X-Agent-Key','$key');`$r.ContentLength=`$b.Length$n"
|
||||
$code += " `$s=`$r.GetRequestStream();`$s.Write(`$b,0,`$b.Length);`$s.Close()$n"
|
||||
$code += " try{`$r.GetResponse().Close()}catch{}$n"
|
||||
$code += " Remove-Item '$dlg' -Force -ErrorAction SilentlyContinue$n"
|
||||
$code += "}$n"
|
||||
|
||||
$code | Out-File -FilePath $dlg -Encoding UTF8 -Force
|
||||
|
||||
$loggedUser = (Get-CimInstance Win32_ComputerSystem).UserName
|
||||
if ($loggedUser) {
|
||||
$tn = "ITNexus-AnnDlg-$id"
|
||||
Unregister-ScheduledTask -TaskName $tn -Confirm:$false -ErrorAction SilentlyContinue
|
||||
$a = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument "-ExecutionPolicy Bypass -WindowStyle Hidden -File `"$dlg`""
|
||||
$t = New-ScheduledTaskTrigger -Once -At (Get-Date).AddSeconds(2)
|
||||
$p = New-ScheduledTaskPrincipal -UserId $loggedUser -LogonType Interactive -RunLevel Limited
|
||||
$s = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Minutes 60) -StartWhenAvailable
|
||||
Register-ScheduledTask -TaskName $tn -Action $a -Trigger $t -Principal $p -Settings $s -Force | Out-Null
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$body = [System.Text.Encoding]::UTF8.GetBytes('{"hostname":"'+$env:COMPUTERNAME+'"}')
|
||||
$req = [System.Net.WebRequest]::Create("$url/api/monitoring/announcements-poll")
|
||||
$req.Method = 'POST'; $req.ContentType = 'application/json'
|
||||
$req.Headers.Add('X-Agent-Key', $key); $req.ContentLength = $body.Length
|
||||
$st = $req.GetRequestStream(); $st.Write($body, 0, $body.Length); $st.Close()
|
||||
$rd = New-Object System.IO.StreamReader($req.GetResponse().GetResponseStream())
|
||||
$data = $rd.ReadToEnd() | ConvertFrom-Json
|
||||
if ($data.announcements) { foreach ($a in $data.announcements) { Show-AnnDialog $a } }
|
||||
} catch {}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"server_url": "https://it-nexus.cereda-systems.de",
|
||||
"agent_key": "DEIN-AGENT-KEY-HIER"
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"server_url": "https://it-nexus.cereda-systems.de",
|
||||
"agent_key": "cereda-agent-2024-secure-key"
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
# IT Nexus Agent - Installer
|
||||
# Cereda Systems GmbH
|
||||
|
||||
$InstallDir = "$env:ProgramData\IT Nexus Agent"
|
||||
$AgentKey = "cereda-agent-2024-secure-key"
|
||||
$ServerUrl = "https://it-nexus.cereda-systems.de"
|
||||
$TaskName = "IT Nexus Agent"
|
||||
|
||||
Write-Host "=== IT Nexus Agent Installation ===" -ForegroundColor Cyan
|
||||
|
||||
# Verzeichnis erstellen
|
||||
if (-not (Test-Path $InstallDir)) {
|
||||
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
|
||||
}
|
||||
|
||||
# Dateien kopieren
|
||||
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
Copy-Item "$ScriptDir\it-nexus-agent.ps1" "$InstallDir\it-nexus-agent.ps1" -Force
|
||||
|
||||
# Config schreiben
|
||||
$config = @{ server_url = $ServerUrl; agent_key = $AgentKey } | ConvertTo-Json
|
||||
Set-Content -Path "$InstallDir\config.json" -Value $config -Force
|
||||
|
||||
# Alten Task entfernen falls vorhanden
|
||||
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue
|
||||
|
||||
# Scheduled Task erstellen (laeuft alle 5 Minuten als SYSTEM)
|
||||
$action = New-ScheduledTaskAction -Execute "powershell.exe" -Argument "-ExecutionPolicy Bypass -NonInteractive -WindowStyle Hidden -File `"$InstallDir\it-nexus-agent.ps1`""
|
||||
$trigger = New-ScheduledTaskTrigger -RepetitionInterval (New-TimeSpan -Minutes 5) -Once -At (Get-Date)
|
||||
$settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Minutes 2) -MultipleInstances IgnoreNew -StartWhenAvailable
|
||||
$principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest -ErrorAction SilentlyContinue
|
||||
if (-not $principal) {
|
||||
$principal = New-ScheduledTaskPrincipal -UserId $env:USERNAME -RunLevel Highest
|
||||
}
|
||||
|
||||
Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger -Settings $settings -Principal $principal -Force | Out-Null
|
||||
|
||||
Write-Host "Installiert in: $InstallDir" -ForegroundColor Green
|
||||
Write-Host "Scheduled Task '$TaskName' erstellt (alle 5 Min)" -ForegroundColor Green
|
||||
Write-Host "Server: $ServerUrl" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Write-Host "Erster Checkin wird jetzt ausgefuehrt..." -ForegroundColor Yellow
|
||||
Start-ScheduledTask -TaskName $TaskName
|
||||
Write-Host "Fertig!" -ForegroundColor Green
|
||||
@@ -1,606 +0,0 @@
|
||||
# IT Nexus Monitoring Agent v1.3
|
||||
# Cereda Systems GmbH
|
||||
# Laedt Systemdaten und sendet sie an IT Nexus
|
||||
|
||||
$AgentVersion = "1.3.1"
|
||||
$ConfigPath = Join-Path $PSScriptRoot "config.json"
|
||||
$LogPath = Join-Path $PSScriptRoot "agent.log"
|
||||
|
||||
function Write-Log($msg) {
|
||||
$line = "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') $msg"
|
||||
Add-Content -Path $LogPath -Value $line -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# Config laden
|
||||
if (-not (Test-Path $ConfigPath)) {
|
||||
Write-Log "ERROR: config.json nicht gefunden"
|
||||
exit 1
|
||||
}
|
||||
$config = Get-Content $ConfigPath | ConvertFrom-Json
|
||||
$ServerUrl = $config.server_url.TrimEnd('/')
|
||||
$AgentKey = $config.agent_key
|
||||
|
||||
function Get-CpuUsage {
|
||||
try {
|
||||
$pc = [System.Diagnostics.PerformanceCounter]::new("Processor", "% Processor Time", "_Total")
|
||||
$pc.NextValue() | Out-Null
|
||||
Start-Sleep -Milliseconds 800
|
||||
return [math]::Round($pc.NextValue(), 1)
|
||||
} catch { return $null }
|
||||
}
|
||||
|
||||
function Get-RamInfo {
|
||||
try {
|
||||
$os = Get-CimInstance Win32_OperatingSystem
|
||||
$total = [math]::Round($os.TotalVisibleMemorySize / 1MB, 2)
|
||||
$free = [math]::Round($os.FreePhysicalMemory / 1MB, 2)
|
||||
return @{ total = $total; used = [math]::Round($total - $free, 2) }
|
||||
} catch { return @{ total = $null; used = $null } }
|
||||
}
|
||||
|
||||
function Get-DiskInfo {
|
||||
try {
|
||||
$disk = Get-PSDrive C
|
||||
$total = [math]::Round(($disk.Used + $disk.Free) / 1GB, 2)
|
||||
$free = [math]::Round($disk.Free / 1GB, 2)
|
||||
return @{ total = $total; free = $free }
|
||||
} catch { return @{ total = $null; free = $null } }
|
||||
}
|
||||
|
||||
function Get-CpuInfo {
|
||||
try {
|
||||
$cpu = Get-CimInstance Win32_Processor | Select-Object -First 1
|
||||
return @{ model = $cpu.Name.Trim(); cores = $cpu.NumberOfCores }
|
||||
} catch { return @{ model = $null; cores = $null } }
|
||||
}
|
||||
|
||||
function Get-MacAddress {
|
||||
try {
|
||||
$mac = (Get-CimInstance Win32_NetworkAdapterConfiguration | Where-Object { $_.IPEnabled } | Select-Object -First 1).MACAddress
|
||||
return $mac
|
||||
} catch { return $null }
|
||||
}
|
||||
|
||||
function Get-InstalledSoftware {
|
||||
try {
|
||||
$paths = @(
|
||||
'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*',
|
||||
'HKLM:\Software\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*'
|
||||
)
|
||||
$apps = Get-ItemProperty $paths -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.DisplayName -and $_.DisplayName -notmatch '^KB\d+' } |
|
||||
Select-Object -ExpandProperty DisplayName -Unique |
|
||||
ForEach-Object { [System.Text.RegularExpressions.Regex]::Replace($_, '[^\x20-\x7E]', '') } |
|
||||
Where-Object { $_.Length -gt 2 } |
|
||||
Sort-Object |
|
||||
Select-Object -First 100
|
||||
return $apps
|
||||
} catch { return @() }
|
||||
}
|
||||
|
||||
function Get-PendingUpdates {
|
||||
try {
|
||||
$session = New-Object -ComObject Microsoft.Update.Session
|
||||
$searcher = $session.CreateUpdateSearcher()
|
||||
$result = $searcher.Search("IsInstalled=0 and Type='Software'")
|
||||
return $result.Updates.Count
|
||||
} catch { return 0 }
|
||||
}
|
||||
|
||||
function Get-UptimeHours {
|
||||
try {
|
||||
$uptime = (Get-Date) - (gcim Win32_OperatingSystem).LastBootUpTime
|
||||
return [math]::Round($uptime.TotalHours, 1)
|
||||
} catch { return $null }
|
||||
}
|
||||
|
||||
function Get-LastUser {
|
||||
try {
|
||||
$user = (Get-CimInstance Win32_ComputerSystem).UserName
|
||||
if ($user) { return $user.Split('\')[-1] }
|
||||
$profile = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\*' |
|
||||
Where-Object { $_.ProfileImagePath -like 'C:\Users\*' -and $_.ProfileImagePath -notlike '*default*' } |
|
||||
Sort-Object { $_.PSChildName } | Select-Object -Last 1
|
||||
return ($profile.ProfileImagePath -split '\\')[-1]
|
||||
} catch { return $null }
|
||||
}
|
||||
|
||||
function Get-TpmInfo {
|
||||
try {
|
||||
$tpm = Get-WmiObject -Namespace "Root\CIMv2\Security\MicrosoftTpm" -Class Win32_Tpm -ErrorAction Stop
|
||||
if ($tpm) {
|
||||
$specVer = $tpm.SpecVersion
|
||||
$isV2 = $specVer -like "*2.0*"
|
||||
return @{ present = $true; version = $specVer; is_v2 = $isV2 }
|
||||
}
|
||||
return @{ present = $false; version = $null; is_v2 = $false }
|
||||
} catch {
|
||||
return @{ present = $false; version = $null; is_v2 = $false }
|
||||
}
|
||||
}
|
||||
|
||||
function Get-SecureBootStatus {
|
||||
try {
|
||||
$sb = Confirm-SecureBootUEFI -ErrorAction Stop
|
||||
return [bool]$sb
|
||||
} catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
function Get-Win11Readiness {
|
||||
$tpm = Get-TpmInfo
|
||||
$sb = Get-SecureBootStatus
|
||||
$cpu = Get-CimInstance Win32_Processor | Select-Object -First 1
|
||||
$ram = (Get-CimInstance Win32_OperatingSystem).TotalVisibleMemorySize / 1MB
|
||||
|
||||
$cpuOk = $cpu -and $cpu.NumberOfCores -ge 2 -and $cpu.MaxClockSpeed -ge 1000
|
||||
$ramOk = $ram -ge 4
|
||||
$ready = $tpm.is_v2 -and $sb -and $cpuOk -and $ramOk
|
||||
|
||||
return @{
|
||||
tpm_present = $tpm.present
|
||||
tpm_version = if ($tpm.version) { $tpm.version } else { $null }
|
||||
tpm_v2 = $tpm.is_v2
|
||||
secure_boot = $sb
|
||||
win11_ready = $ready
|
||||
}
|
||||
}
|
||||
|
||||
# Systemdaten sammeln
|
||||
Write-Log "Sammle Systemdaten..."
|
||||
|
||||
$cpu = Get-CpuInfo
|
||||
$ram = Get-RamInfo
|
||||
$disk = Get-DiskInfo
|
||||
$cpuPct = Get-CpuUsage
|
||||
$mac = Get-MacAddress
|
||||
$updates = Get-PendingUpdates
|
||||
$uptime = Get-UptimeHours
|
||||
$lastUser = Get-LastUser
|
||||
$software = Get-InstalledSoftware
|
||||
$win11 = Get-Win11Readiness
|
||||
|
||||
$ip = (Get-NetIPAddress -AddressFamily IPv4 | Where-Object {
|
||||
$_.InterfaceAlias -notlike '*Loopback*' -and
|
||||
$_.InterfaceAlias -notlike '*vEthernet*' -and
|
||||
$_.InterfaceAlias -notlike '*WSL*' -and
|
||||
$_.InterfaceAlias -notlike '*Virtual*' -and
|
||||
$_.IPAddress -notlike '169.*' -and
|
||||
$_.IPAddress -notlike '172.*'
|
||||
} | Select-Object -First 1).IPAddress
|
||||
if (-not $ip) {
|
||||
$ip = (Get-NetIPAddress -AddressFamily IPv4 | Where-Object {
|
||||
$_.InterfaceAlias -notlike '*Loopback*' -and
|
||||
$_.InterfaceAlias -notlike '*vEthernet*' -and
|
||||
$_.IPAddress -notlike '169.*'
|
||||
} | Select-Object -First 1).IPAddress
|
||||
}
|
||||
$os = Get-CimInstance Win32_OperatingSystem
|
||||
$domain = $env:USERDOMAIN
|
||||
|
||||
$payload = @{
|
||||
hostname = $env:COMPUTERNAME
|
||||
ip_address = $ip
|
||||
mac_address = $mac
|
||||
os_name = $os.Caption
|
||||
os_version = $os.Version
|
||||
cpu_model = $cpu.model
|
||||
cpu_cores = $cpu.cores
|
||||
cpu_usage_percent = $cpuPct
|
||||
ram_total_gb = $ram.total
|
||||
ram_used_gb = $ram.used
|
||||
disk_total_gb = $disk.total
|
||||
disk_free_gb = $disk.free
|
||||
last_user = $lastUser
|
||||
uptime_hours = $uptime
|
||||
domain = $domain
|
||||
agent_version = $AgentVersion
|
||||
installed_software = $software
|
||||
windows_updates_pending = $updates
|
||||
tpm_present = $win11.tpm_present
|
||||
tpm_version = $win11.tpm_version
|
||||
tpm_v2 = $win11.tpm_v2
|
||||
secure_boot = $win11.secure_boot
|
||||
win11_ready = $win11.win11_ready
|
||||
} | ConvertTo-Json -Depth 3
|
||||
|
||||
# --- An IT Nexus senden ---
|
||||
function Invoke-PatchCommand($cmd, $cmdId) {
|
||||
Write-Log "PATCH: Command empfangen: $($cmd) (ID: $cmdId)"
|
||||
$result = "OK"
|
||||
try {
|
||||
switch ($cmd) {
|
||||
'check_updates' {
|
||||
UsoClient.exe StartScan 2>$null
|
||||
Start-Sleep -Seconds 5
|
||||
$result = "Update-Scan gestartet"
|
||||
Write-Log "PATCH: Update-Scan gestartet"
|
||||
}
|
||||
'install_updates' {
|
||||
UsoClient.exe StartDownload 2>$null
|
||||
Start-Sleep -Seconds 3
|
||||
UsoClient.exe StartInstall 2>$null
|
||||
$result = "Update-Installation gestartet"
|
||||
Write-Log "PATCH: Update-Installation gestartet"
|
||||
}
|
||||
'update_agent' {
|
||||
Write-Log "UPDATE: Manuelles Agent-Update angefordert..."
|
||||
try {
|
||||
$scriptPath = $MyInvocation.MyCommand.Path
|
||||
$tempPath = "$scriptPath.update"
|
||||
$updateHeaders = @{ 'X-Agent-Key' = $AgentKey }
|
||||
Invoke-WebRequest -Uri "$ServerUrl/api/monitoring/agent-script" `
|
||||
-Headers $updateHeaders -OutFile $tempPath -TimeoutSec 30
|
||||
if ((Get-Item $tempPath -ErrorAction SilentlyContinue).Length -gt 1024) {
|
||||
Copy-Item -Path $tempPath -Destination $scriptPath -Force
|
||||
Remove-Item $tempPath -Force -ErrorAction SilentlyContinue
|
||||
$result = "Agent erfolgreich aktualisiert - neue Version aktiv ab naechstem Run"
|
||||
Write-Log "UPDATE: Manuelles Update erfolgreich"
|
||||
} else {
|
||||
Remove-Item $tempPath -Force -ErrorAction SilentlyContinue
|
||||
$result = "Fehler: Heruntergeladene Datei ungueltig"
|
||||
Write-Log "UPDATE ERROR: Datei zu klein"
|
||||
}
|
||||
} catch {
|
||||
$result = "Fehler: $($_.Exception.Message)"
|
||||
Write-Log "UPDATE ERROR: $result"
|
||||
}
|
||||
}
|
||||
'upgrade_win11' {
|
||||
Write-Log "WIN11: Starte Windows 11 Upgrade-Prozess..."
|
||||
$assistantPath = "C:\ProgramData\IT Nexus Agent\Win11Upgrade.exe"
|
||||
try {
|
||||
Write-Log "WIN11: Lade Installation Assistant herunter..."
|
||||
Invoke-WebRequest -Uri "https://go.microsoft.com/fwlink/?linkid=2171764" -OutFile $assistantPath -TimeoutSec 300
|
||||
if ((Get-Item $assistantPath -ErrorAction SilentlyContinue).Length -gt 1MB) {
|
||||
# Als angemeldeter Benutzer ausfuehren damit UI sichtbar ist
|
||||
$loggedInUser = (Get-CimInstance Win32_ComputerSystem).UserName
|
||||
if ($loggedInUser -and $loggedInUser -ne '') {
|
||||
Unregister-ScheduledTask -TaskName "IT Nexus Win11 Upgrade" -Confirm:$false -ErrorAction SilentlyContinue
|
||||
$action = New-ScheduledTaskAction -Execute $assistantPath -Argument "/skipeula /auto upgrade"
|
||||
$trigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddSeconds(20)
|
||||
$principal = New-ScheduledTaskPrincipal -UserId $loggedInUser -LogonType Interactive -RunLevel Highest
|
||||
$settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Hours 3)
|
||||
Register-ScheduledTask -TaskName "IT Nexus Win11 Upgrade" -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null
|
||||
Start-ScheduledTask -TaskName "IT Nexus Win11 Upgrade"
|
||||
$reportBody = @{ command_id = $cmdId; status = 'running'; result = "Windows 11 Upgrade gestartet fuer Benutzer $loggedInUser - Fortschritt sichtbar auf dem Geraet" } | ConvertTo-Json
|
||||
Invoke-RestMethod -Uri "$ServerUrl/api/patch/commands/result" -Method POST -Body $reportBody -Headers @{ 'Content-Type' = 'application/json'; 'X-Agent-Key' = $AgentKey } -TimeoutSec 10 -ErrorAction SilentlyContinue
|
||||
Write-Log "WIN11: Upgrade als $loggedInUser gestartet (Status: running)"
|
||||
return
|
||||
} else {
|
||||
Start-Process -FilePath $assistantPath -ArgumentList "/quietinstall /skipeula /auto upgrade" -NoNewWindow
|
||||
$reportBody = @{ command_id = $cmdId; status = 'running'; result = "Windows 11 Upgrade gestartet (kein Benutzer angemeldet)" } | ConvertTo-Json
|
||||
Invoke-RestMethod -Uri "$ServerUrl/api/patch/commands/result" -Method POST -Body $reportBody -Headers @{ 'Content-Type' = 'application/json'; 'X-Agent-Key' = $AgentKey } -TimeoutSec 10 -ErrorAction SilentlyContinue
|
||||
Write-Log "WIN11: Upgrade als SYSTEM gestartet (kein User)"
|
||||
return
|
||||
}
|
||||
} else {
|
||||
$result = "Fehler: Download fehlgeschlagen oder Datei zu klein"
|
||||
Write-Log "WIN11 ERROR: Download fehlgeschlagen"
|
||||
}
|
||||
} catch {
|
||||
$result = "Fehler: $($_.Exception.Message)"
|
||||
Write-Log "WIN11 ERROR: $result"
|
||||
}
|
||||
}
|
||||
'reboot' {
|
||||
$result = "Neustart wird in 60 Sekunden durchgefuehrt"
|
||||
Write-Log "PATCH: Neustart geplant"
|
||||
$reportBody = @{ command_id = $cmdId; status = 'done'; result = $result } | ConvertTo-Json
|
||||
Invoke-RestMethod -Uri "$ServerUrl/api/patch/commands/result" `
|
||||
-Method POST -Body $reportBody `
|
||||
-Headers @{ 'Content-Type' = 'application/json'; 'X-Agent-Key' = $AgentKey } `
|
||||
-TimeoutSec 10 -ErrorAction SilentlyContinue
|
||||
Start-Sleep -Seconds 5
|
||||
shutdown.exe /r /t 60 /c "IT Nexus Patch Management - Geplanter Neustart"
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
$result = "Fehler: $($_.Exception.Message)"
|
||||
Write-Log "PATCH ERROR: $result"
|
||||
}
|
||||
|
||||
try {
|
||||
$reportBody = @{ command_id = $cmdId; status = 'done'; result = $result } | ConvertTo-Json
|
||||
Invoke-RestMethod -Uri "$ServerUrl/api/patch/commands/result" `
|
||||
-Method POST -Body $reportBody `
|
||||
-Headers @{ 'Content-Type' = 'application/json'; 'X-Agent-Key' = $AgentKey } `
|
||||
-TimeoutSec 10
|
||||
} catch {
|
||||
Write-Log "PATCH: Ergebnis-Meldung fehlgeschlagen: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$headers = @{
|
||||
'Content-Type' = 'application/json'
|
||||
'X-Agent-Key' = $AgentKey
|
||||
}
|
||||
$cleanPayload = $payload -replace '[\x00-\x1F\x7F]', ''
|
||||
$response = Invoke-RestMethod -Uri "$ServerUrl/api/monitoring/checkin" `
|
||||
-Method POST -Body $cleanPayload -Headers $headers -TimeoutSec 30
|
||||
Write-Log "OK: Checkin erfolgreich (Hostname: $env:COMPUTERNAME)"
|
||||
|
||||
# Self-Update pruefen
|
||||
$serverVersion = $response.agent_version
|
||||
if ($serverVersion -and $serverVersion -ne $AgentVersion) {
|
||||
Write-Log "UPDATE: Neue Agent-Version verfuegbar: $serverVersion (aktuell: $AgentVersion)"
|
||||
try {
|
||||
$scriptPath = $MyInvocation.MyCommand.Path
|
||||
$tempPath = "$scriptPath.update"
|
||||
$updateHeaders = @{ 'X-Agent-Key' = $AgentKey }
|
||||
Invoke-WebRequest -Uri "$ServerUrl/api/monitoring/agent-script" `
|
||||
-Headers $updateHeaders -OutFile $tempPath -TimeoutSec 30
|
||||
if ((Get-Item $tempPath).Length -gt 1024) {
|
||||
Copy-Item -Path $tempPath -Destination $scriptPath -Force
|
||||
Remove-Item $tempPath -Force -ErrorAction SilentlyContinue
|
||||
Write-Log "UPDATE: Agent erfolgreich auf Version $serverVersion aktualisiert."
|
||||
} else {
|
||||
Remove-Item $tempPath -Force -ErrorAction SilentlyContinue
|
||||
Write-Log "UPDATE: Heruntergeladene Datei zu klein - Update abgebrochen"
|
||||
}
|
||||
} catch {
|
||||
Write-Log "UPDATE ERROR: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
# Laufende Commands pruefen (z.B. Win11 Upgrade)
|
||||
if ($response.running_commands -and $response.running_commands.Count -gt 0) {
|
||||
foreach ($rc in $response.running_commands) {
|
||||
if ($rc.command -eq 'upgrade_win11') {
|
||||
$task = Get-ScheduledTask -TaskName "IT Nexus Win11 Upgrade" -ErrorAction SilentlyContinue
|
||||
$proc = Get-Process -Name "Win11Upgrade" -ErrorAction SilentlyContinue
|
||||
$stillRunning = ($task -and $task.State -eq 'Running') -or ($proc -ne $null)
|
||||
if (-not $stillRunning) {
|
||||
$taskInfo = Get-ScheduledTaskInfo -TaskName "IT Nexus Win11 Upgrade" -ErrorAction SilentlyContinue
|
||||
$exitCode = if ($taskInfo) { $taskInfo.LastTaskResult } else { 0 }
|
||||
$doneResult = if ($exitCode -eq 0) { "Windows 11 Upgrade abgeschlossen" } else { "Upgrade beendet (Code: $exitCode)" }
|
||||
$rb = @{ command_id = $rc.id; status = 'done'; result = $doneResult } | ConvertTo-Json
|
||||
Invoke-RestMethod -Uri "$ServerUrl/api/patch/commands/result" -Method POST -Body $rb -Headers @{ 'Content-Type' = 'application/json'; 'X-Agent-Key' = $AgentKey } -TimeoutSec 10 -ErrorAction SilentlyContinue
|
||||
Write-Log "WIN11: Upgrade abgeschlossen (Code: $exitCode)"
|
||||
} else {
|
||||
Write-Log "WIN11: Upgrade laeuft noch..."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Ankuendigungen anzeigen (als eingeloggter Benutzer via ScheduledTask)
|
||||
if ($response.announcements -and $response.announcements.Count -gt 0) {
|
||||
foreach ($ann in $response.announcements) {
|
||||
try {
|
||||
$annId = $ann.id
|
||||
$annTitle = $ann.title -replace "'", "''"
|
||||
$annMessage = $ann.message -replace "'", "''"
|
||||
$typeLabel = switch ($ann.type) {
|
||||
'warning' { 'WICHTIGE WARNUNG' }
|
||||
'maintenance' { 'WARTUNGSANKUENDIGUNG' }
|
||||
default { 'INFORMATION' }
|
||||
}
|
||||
$accentColor = switch ($ann.type) {
|
||||
'warning' { '220, 50, 50' }
|
||||
'maintenance' { '245, 158, 11' }
|
||||
default { '99, 102, 241' }
|
||||
}
|
||||
|
||||
# PS-Script das im Benutzer-Kontext laeuft und den Dialog zeigt
|
||||
$dialogScript = @"
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
`$form = New-Object System.Windows.Forms.Form
|
||||
`$form.Text = 'IT Nexus - $typeLabel'
|
||||
`$form.Size = New-Object System.Drawing.Size(520, 330)
|
||||
`$form.StartPosition = 'CenterScreen'
|
||||
`$form.FormBorderStyle = 'FixedDialog'
|
||||
`$form.MaximizeBox = `$false
|
||||
`$form.MinimizeBox = `$false
|
||||
`$form.TopMost = `$true
|
||||
`$form.BackColor = [System.Drawing.Color]::FromArgb(24, 24, 37)
|
||||
`$lTitle = New-Object System.Windows.Forms.Label
|
||||
`$lTitle.Text = '$annTitle'
|
||||
`$lTitle.Font = New-Object System.Drawing.Font('Segoe UI', 13, [System.Drawing.FontStyle]::Bold)
|
||||
`$lTitle.ForeColor = [System.Drawing.Color]::White
|
||||
`$lTitle.Location = New-Object System.Drawing.Point(20, 20)
|
||||
`$lTitle.Size = New-Object System.Drawing.Size(460, 36)
|
||||
`$form.Controls.Add(`$lTitle)
|
||||
`$lMsg = New-Object System.Windows.Forms.Label
|
||||
`$lMsg.Text = '$annMessage'
|
||||
`$lMsg.Font = New-Object System.Drawing.Font('Segoe UI', 10)
|
||||
`$lMsg.ForeColor = [System.Drawing.Color]::FromArgb(200, 200, 220)
|
||||
`$lMsg.Location = New-Object System.Drawing.Point(20, 66)
|
||||
`$lMsg.Size = New-Object System.Drawing.Size(460, 155)
|
||||
`$lMsg.AutoSize = `$false
|
||||
`$form.Controls.Add(`$lMsg)
|
||||
`$lHint = New-Object System.Windows.Forms.Label
|
||||
`$lHint.Text = 'Bitte lesen und Kenntnisnahme bestaetigen.'
|
||||
`$lHint.Font = New-Object System.Drawing.Font('Segoe UI', 8, [System.Drawing.FontStyle]::Italic)
|
||||
`$lHint.ForeColor = [System.Drawing.Color]::FromArgb(130, 130, 150)
|
||||
`$lHint.Location = New-Object System.Drawing.Point(20, 228)
|
||||
`$lHint.Size = New-Object System.Drawing.Size(460, 20)
|
||||
`$form.Controls.Add(`$lHint)
|
||||
`$btn = New-Object System.Windows.Forms.Button
|
||||
`$btn.Text = 'Gelesen und bestaetigt'
|
||||
`$btn.Font = New-Object System.Drawing.Font('Segoe UI', 10, [System.Drawing.FontStyle]::Bold)
|
||||
`$btn.ForeColor = [System.Drawing.Color]::White
|
||||
`$btn.BackColor = [System.Drawing.Color]::FromArgb($accentColor)
|
||||
`$btn.FlatStyle = 'Flat'
|
||||
`$btn.Location = New-Object System.Drawing.Point(20, 255)
|
||||
`$btn.Size = New-Object System.Drawing.Size(460, 40)
|
||||
`$btn.DialogResult = [System.Windows.Forms.DialogResult]::OK
|
||||
`$form.Controls.Add(`$btn)
|
||||
`$form.AcceptButton = `$btn
|
||||
`$r = `$form.ShowDialog()
|
||||
if (`$r -eq [System.Windows.Forms.DialogResult]::OK) {
|
||||
`$b = [System.Text.Encoding]::UTF8.GetBytes('{"hostname":"' + `$env:COMPUTERNAME + '"}')
|
||||
`$req = [System.Net.WebRequest]::Create('$ServerUrl/api/announcements/$annId/ack-agent')
|
||||
`$req.Method = 'POST'
|
||||
`$req.ContentType = 'application/json'
|
||||
`$req.Headers.Add('X-Agent-Key','$AgentKey')
|
||||
`$req.ContentLength = `$b.Length
|
||||
`$s = `$req.GetRequestStream(); `$s.Write(`$b,0,`$b.Length); `$s.Close()
|
||||
try { `$req.GetResponse().Close() } catch {}
|
||||
}
|
||||
"@
|
||||
# Dialog-Script temporaer speichern
|
||||
$scriptFile = "C:\ProgramData\IT Nexus Agent\ann_$annId.ps1"
|
||||
$dialogScript | Out-File -FilePath $scriptFile -Encoding UTF8 -Force
|
||||
|
||||
# Eingeloggten Benutzer ermitteln
|
||||
$loggedUser = (Get-CimInstance Win32_ComputerSystem).UserName
|
||||
if ($loggedUser -and $loggedUser -ne '') {
|
||||
$taskName = "ITNexus-Ann-$annId"
|
||||
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
|
||||
$action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument "-ExecutionPolicy Bypass -WindowStyle Normal -File `"$scriptFile`""
|
||||
$trigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddSeconds(3)
|
||||
$principal = New-ScheduledTaskPrincipal -UserId $loggedUser -LogonType Interactive -RunLevel Limited
|
||||
$settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Minutes 60) -StartWhenAvailable
|
||||
Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null
|
||||
Write-Log "ANNOUNCEMENT: Dialog fuer Benutzer $loggedUser gestartet - ID $annId"
|
||||
} else {
|
||||
Write-Log "ANNOUNCEMENT: Kein Benutzer eingeloggt - Dialog wird beim naechsten Check-in versucht"
|
||||
}
|
||||
} catch {
|
||||
Write-Log "ANNOUNCEMENT ERROR: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Pending Patch-Commands verarbeiten
|
||||
if ($response.commands -and $response.commands.Count -gt 0) {
|
||||
Write-Log "PATCH: $($response.commands.Count) Command(s) empfangen"
|
||||
foreach ($cmd in $response.commands) {
|
||||
Invoke-PatchCommand -cmd $cmd.command -cmdId $cmd.id
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
$statusCode = $_.Exception.Response.StatusCode.Value__
|
||||
Write-Log "ERROR: Checkin fehlgeschlagen - HTTP $statusCode - $($_.Exception.Message)"
|
||||
try {
|
||||
$reader = New-Object System.IO.StreamReader($_.Exception.Response.GetResponseStream())
|
||||
Write-Log "ERROR Detail: $($reader.ReadToEnd())"
|
||||
} catch {}
|
||||
}
|
||||
|
||||
# ── Schneller Announcement-Poll (alle 15 Sekunden im Hintergrund) ─────────────
|
||||
function Invoke-AnnouncementPoll {
|
||||
try {
|
||||
$pollBody = @{ hostname = $env:COMPUTERNAME } | ConvertTo-Json
|
||||
$pollResp = Invoke-RestMethod -Uri "$ServerUrl/api/monitoring/announcements-poll" `
|
||||
-Method POST -Body $pollBody `
|
||||
-Headers @{ 'Content-Type' = 'application/json'; 'X-Agent-Key' = $AgentKey } `
|
||||
-TimeoutSec 10
|
||||
if ($pollResp.announcements -and $pollResp.announcements.Count -gt 0) {
|
||||
foreach ($ann in $pollResp.announcements) {
|
||||
$annId = $ann.id
|
||||
$annTitle = $ann.title -replace "'", "''"
|
||||
$annMessage = $ann.message -replace "'", "''"
|
||||
$accentColor = switch ($ann.type) {
|
||||
'warning' { '220, 50, 50' }
|
||||
'maintenance' { '245, 158, 11' }
|
||||
default { '99, 102, 241' }
|
||||
}
|
||||
$typeLabel = switch ($ann.type) {
|
||||
'warning' { 'WICHTIGE WARNUNG' }
|
||||
'maintenance' { 'WARTUNGSANKUENDIGUNG' }
|
||||
default { 'INFORMATION' }
|
||||
}
|
||||
$dialogScript = @"
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
`$form = New-Object System.Windows.Forms.Form
|
||||
`$form.Text = 'IT Nexus - $typeLabel'
|
||||
`$form.Size = New-Object System.Drawing.Size(520, 330)
|
||||
`$form.StartPosition = 'CenterScreen'
|
||||
`$form.FormBorderStyle = 'FixedDialog'
|
||||
`$form.MaximizeBox = `$false
|
||||
`$form.MinimizeBox = `$false
|
||||
`$form.TopMost = `$true
|
||||
`$form.BackColor = [System.Drawing.Color]::FromArgb(24, 24, 37)
|
||||
`$lTitle = New-Object System.Windows.Forms.Label
|
||||
`$lTitle.Text = '$annTitle'
|
||||
`$lTitle.Font = New-Object System.Drawing.Font('Segoe UI', 13, [System.Drawing.FontStyle]::Bold)
|
||||
`$lTitle.ForeColor = [System.Drawing.Color]::White
|
||||
`$lTitle.Location = New-Object System.Drawing.Point(20, 20)
|
||||
`$lTitle.Size = New-Object System.Drawing.Size(460, 36)
|
||||
`$form.Controls.Add(`$lTitle)
|
||||
`$lMsg = New-Object System.Windows.Forms.Label
|
||||
`$lMsg.Text = '$annMessage'
|
||||
`$lMsg.Font = New-Object System.Drawing.Font('Segoe UI', 10)
|
||||
`$lMsg.ForeColor = [System.Drawing.Color]::FromArgb(200, 200, 220)
|
||||
`$lMsg.Location = New-Object System.Drawing.Point(20, 66)
|
||||
`$lMsg.Size = New-Object System.Drawing.Size(460, 155)
|
||||
`$lMsg.AutoSize = `$false
|
||||
`$form.Controls.Add(`$lMsg)
|
||||
`$lHint = New-Object System.Windows.Forms.Label
|
||||
`$lHint.Text = 'Bitte lesen und Kenntnisnahme bestaetigen.'
|
||||
`$lHint.Font = New-Object System.Drawing.Font('Segoe UI', 8, [System.Drawing.FontStyle]::Italic)
|
||||
`$lHint.ForeColor = [System.Drawing.Color]::FromArgb(130, 130, 150)
|
||||
`$lHint.Location = New-Object System.Drawing.Point(20, 228)
|
||||
`$lHint.Size = New-Object System.Drawing.Size(460, 20)
|
||||
`$form.Controls.Add(`$lHint)
|
||||
`$btn = New-Object System.Windows.Forms.Button
|
||||
`$btn.Text = 'Gelesen und bestaetigt'
|
||||
`$btn.Font = New-Object System.Drawing.Font('Segoe UI', 10, [System.Drawing.FontStyle]::Bold)
|
||||
`$btn.ForeColor = [System.Drawing.Color]::White
|
||||
`$btn.BackColor = [System.Drawing.Color]::FromArgb($accentColor)
|
||||
`$btn.FlatStyle = 'Flat'
|
||||
`$btn.Location = New-Object System.Drawing.Point(20, 255)
|
||||
`$btn.Size = New-Object System.Drawing.Size(460, 40)
|
||||
`$btn.DialogResult = [System.Windows.Forms.DialogResult]::OK
|
||||
`$form.Controls.Add(`$btn)
|
||||
`$form.AcceptButton = `$btn
|
||||
`$r = `$form.ShowDialog()
|
||||
if (`$r -eq [System.Windows.Forms.DialogResult]::OK) {
|
||||
`$b = [System.Text.Encoding]::UTF8.GetBytes('{"hostname":"' + `$env:COMPUTERNAME + '"}')
|
||||
`$req = [System.Net.WebRequest]::Create('$ServerUrl/api/announcements/$annId/ack-agent')
|
||||
`$req.Method = 'POST'; `$req.ContentType = 'application/json'
|
||||
`$req.Headers.Add('X-Agent-Key','$AgentKey')
|
||||
`$req.ContentLength = `$b.Length
|
||||
`$s = `$req.GetRequestStream(); `$s.Write(`$b,0,`$b.Length); `$s.Close()
|
||||
try { `$req.GetResponse().Close() } catch {}
|
||||
}
|
||||
"@
|
||||
$scriptFile = "C:\ProgramData\IT Nexus Agent\ann_$annId.ps1"
|
||||
$dialogScript | Out-File -FilePath $scriptFile -Encoding UTF8 -Force
|
||||
$loggedUser = (Get-CimInstance Win32_ComputerSystem).UserName
|
||||
if ($loggedUser) {
|
||||
$taskName = "ITNexus-Ann-$annId"
|
||||
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
|
||||
$action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument "-ExecutionPolicy Bypass -WindowStyle Normal -File `"$scriptFile`""
|
||||
$trigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddSeconds(2)
|
||||
$principal = New-ScheduledTaskPrincipal -UserId $loggedUser -LogonType Interactive -RunLevel Limited
|
||||
$settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Minutes 60) -StartWhenAvailable
|
||||
Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null
|
||||
Write-Log "ANNOUNCEMENT: Sofort-Dialog gestartet fuer $loggedUser - ID $annId"
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
# Stiller Fehler - nicht kritisch
|
||||
}
|
||||
}
|
||||
|
||||
# Announcement-Watcher-Task einrichten (alle 15 Sek, bleibt dauerhaft aktiv)
|
||||
try {
|
||||
$watcherName = 'IT Nexus Announcement Watcher'
|
||||
$watcherScript = 'C:\ProgramData\IT Nexus Agent\ann-watcher.ps1'
|
||||
|
||||
# Watcher-Script vom Server laden (immer aktuell)
|
||||
Invoke-WebRequest -Uri "$ServerUrl/api/monitoring/ann-watcher" `
|
||||
-Headers @{ 'X-Agent-Key' = $AgentKey } `
|
||||
-OutFile $watcherScript -TimeoutSec 15 -ErrorAction SilentlyContinue
|
||||
|
||||
# Task registrieren falls noch nicht vorhanden
|
||||
if (-not (Get-ScheduledTask -TaskName $watcherName -ErrorAction SilentlyContinue)) {
|
||||
$wAct = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument "-ExecutionPolicy Bypass -NonInteractive -WindowStyle Hidden -File `"$watcherScript`""
|
||||
$wTrg = New-ScheduledTaskTrigger -RepetitionInterval (New-TimeSpan -Seconds 15) -Once -At (Get-Date)
|
||||
$wSet = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Seconds 14) -MultipleInstances IgnoreNew -StartWhenAvailable
|
||||
$wPri = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest
|
||||
Register-ScheduledTask -TaskName $watcherName -Action $wAct -Trigger $wTrg -Settings $wSet -Principal $wPri -Force | Out-Null
|
||||
Write-Log "ANNOUNCEMENT: Watcher-Task registriert (alle 15 Sekunden)"
|
||||
}
|
||||
} catch {
|
||||
Write-Log "ANNOUNCEMENT WATCHER ERROR: $($_.Exception.Message)"
|
||||
}
|
||||
103
agent/setup.iss
103
agent/setup.iss
@@ -1,103 +0,0 @@
|
||||
#define MyAppName "IT Nexus Agent"
|
||||
#define MyAppVersion "1.2.4"
|
||||
#define MyAppPublisher "Cereda Systems GmbH"
|
||||
#define MyAppURL "https://it-nexus.cereda-systems.de"
|
||||
#define MyAppExeName "it-nexus-agent.ps1"
|
||||
#define InstallDir "{commonappdata}\IT Nexus Agent"
|
||||
|
||||
[Setup]
|
||||
AppId={{B3F7A2C1-4E8D-4F2A-9B1C-7D3E5F6A8B2C}
|
||||
AppName={#MyAppName}
|
||||
AppVersion={#MyAppVersion}
|
||||
AppPublisher={#MyAppPublisher}
|
||||
AppPublisherURL={#MyAppURL}
|
||||
DefaultDirName={#InstallDir}
|
||||
DisableDirPage=yes
|
||||
DefaultGroupName={#MyAppName}
|
||||
DisableProgramGroupPage=yes
|
||||
OutputDir=..\dist
|
||||
OutputBaseFilename=IT-Nexus-Agent-Setup-v{#MyAppVersion}
|
||||
SetupIconFile=
|
||||
Compression=lzma2/ultra64
|
||||
SolidCompression=yes
|
||||
WizardStyle=modern
|
||||
WizardSizePercent=120
|
||||
PrivilegesRequired=admin
|
||||
UninstallDisplayName={#MyAppName}
|
||||
UninstallDisplayIcon={app}\it-nexus-agent.ps1
|
||||
CloseApplications=no
|
||||
DisableWelcomePage=no
|
||||
WizardImageFile=compiler:WizModernImage.bmp
|
||||
WizardSmallImageFile=compiler:WizModernSmallImage.bmp
|
||||
|
||||
[Languages]
|
||||
Name: "german"; MessagesFile: "compiler:Languages\German.isl"
|
||||
|
||||
[Files]
|
||||
Source: "it-nexus-agent.ps1"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "config.json.template"; DestDir: "{app}"; DestName: "config.json"; Flags: ignoreversion onlyifdoesntexist
|
||||
|
||||
[Run]
|
||||
Filename: "powershell.exe"; Parameters: "-ExecutionPolicy Bypass -NonInteractive -Command ""Unregister-ScheduledTask -TaskName 'IT Nexus Agent' -Confirm:$false -ErrorAction SilentlyContinue; $a = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument '-ExecutionPolicy Bypass -NonInteractive -WindowStyle Hidden -File \""{app}\it-nexus-agent.ps1\""'; $t = New-ScheduledTaskTrigger -RepetitionInterval (New-TimeSpan -Minutes 1) -Once -At (Get-Date); $s = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Minutes 2) -MultipleInstances IgnoreNew -StartWhenAvailable; $p = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest; Register-ScheduledTask -TaskName 'IT Nexus Agent' -Action $a -Trigger $t -Settings $s -Principal $p -Force; Start-ScheduledTask -TaskName 'IT Nexus Agent'"""; Flags: runhidden waituntilterminated; StatusMsg: "Registriere Windows Scheduled Task..."
|
||||
|
||||
[UninstallRun]
|
||||
Filename: "powershell.exe"; Parameters: "-ExecutionPolicy Bypass -NonInteractive -Command ""Unregister-ScheduledTask -TaskName 'IT Nexus Agent' -Confirm:$false -ErrorAction SilentlyContinue"""; Flags: runhidden waituntilterminated
|
||||
|
||||
[Code]
|
||||
var
|
||||
ServerUrlPage: TInputQueryWizardPage;
|
||||
AgentKeyPage: TInputQueryWizardPage;
|
||||
|
||||
procedure InitializeWizard;
|
||||
begin
|
||||
ServerUrlPage := CreateInputQueryPage(wpWelcome,
|
||||
'Server-Konfiguration',
|
||||
'Gib die IT Nexus Server-Adresse ein.',
|
||||
'');
|
||||
ServerUrlPage.Add('Server URL:', False);
|
||||
ServerUrlPage.Values[0] := 'https://it-nexus.cereda-systems.de';
|
||||
|
||||
AgentKeyPage := CreateInputQueryPage(ServerUrlPage.ID,
|
||||
'API Key',
|
||||
'Gib den Agent API Key ein. Diesen findest du in den IT Nexus Einstellungen.',
|
||||
'');
|
||||
AgentKeyPage.Add('Agent API Key:', False);
|
||||
AgentKeyPage.Values[0] := 'itx-4CPJPTHmCfdrL9D62WacCATEvuvULXcp7ECMpSaNUjsS344F6_L4Ug';
|
||||
end;
|
||||
|
||||
function NextButtonClick(CurPageID: Integer): Boolean;
|
||||
begin
|
||||
Result := True;
|
||||
if CurPageID = ServerUrlPage.ID then begin
|
||||
if ServerUrlPage.Values[0] = '' then begin
|
||||
MsgBox('Bitte gib eine Server URL ein.', mbError, MB_OK);
|
||||
Result := False;
|
||||
end;
|
||||
end;
|
||||
if CurPageID = AgentKeyPage.ID then begin
|
||||
if AgentKeyPage.Values[0] = '' then begin
|
||||
MsgBox('Bitte gib einen API Key ein.', mbError, MB_OK);
|
||||
Result := False;
|
||||
end;
|
||||
end;
|
||||
end;
|
||||
|
||||
procedure CurStepChanged(CurStep: TSetupStep);
|
||||
var
|
||||
ConfigFile: string;
|
||||
Config: TStringList;
|
||||
begin
|
||||
if CurStep = ssPostInstall then begin
|
||||
ConfigFile := ExpandConstant('{app}\config.json');
|
||||
Config := TStringList.Create;
|
||||
try
|
||||
Config.Add('{');
|
||||
Config.Add(' "server_url": "' + ServerUrlPage.Values[0] + '",');
|
||||
Config.Add(' "agent_key": "' + AgentKeyPage.Values[0] + '"');
|
||||
Config.Add('}');
|
||||
Config.SaveToFile(ConfigFile);
|
||||
finally
|
||||
Config.Free;
|
||||
end;
|
||||
end;
|
||||
end;
|
||||
@@ -1,8 +0,0 @@
|
||||
# IT Nexus Agent - Uninstaller
|
||||
$InstallDir = "C:\Program Files\IT Nexus Agent"
|
||||
$TaskName = "IT Nexus Agent"
|
||||
|
||||
Write-Host "=== IT Nexus Agent Deinstallation ===" -ForegroundColor Yellow
|
||||
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue
|
||||
Remove-Item -Path $InstallDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Write-Host "Agent entfernt." -ForegroundColor Green
|
||||
@@ -4,6 +4,60 @@ const { asyncHandler } = require('../middleware/errorHandler');
|
||||
const { getPendingCommands } = require('./patch.controller');
|
||||
const { getDatabase } = require('../config/database');
|
||||
const { getForAgent } = require('./announcement.controller');
|
||||
const { validateAgentKey } = require('../utils/agentAuth');
|
||||
const crypto = require('crypto');
|
||||
|
||||
// Code-seitiger Fallback falls AGENT_VERSION env-Var fehlt — bei jedem Agent-Release mit hochziehen!
|
||||
// (War zuvor an mehreren Stellen hartcodiert auf alte Versionen wie '2.0.0'/'1.2.4' — echter
|
||||
// Downgrade-Bug für Agents ohne Patch-Gruppe oder ohne ?hostname-Parameter.)
|
||||
const FALLBACK_AGENT_VERSION = '2.8.0';
|
||||
|
||||
// Vergleicht zwei "x.y.z"-Versionsstrings. >0 wenn a>b, <0 wenn a<b, 0 wenn gleich.
|
||||
function compareVersions(a, b) {
|
||||
const pa = String(a || '0').split('.').map(n => parseInt(n, 10) || 0);
|
||||
const pb = String(b || '0').split('.').map(n => parseInt(n, 10) || 0);
|
||||
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
||||
const diff = (pa[i] || 0) - (pb[i] || 0);
|
||||
if (diff !== 0) return diff;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Nie eine ältere Version als die aktuell installierte vorschlagen/ausliefern — verhindert
|
||||
// Downgrades durch fehlerhafte ENV-Konfiguration, falsch gesetzte Gruppen-Zielversionen o.ä.
|
||||
function clampToNotOlder(targetVersion, currentVersion) {
|
||||
if (!currentVersion) return targetVersion;
|
||||
return compareVersions(targetVersion, currentVersion) < 0 ? currentVersion : targetVersion;
|
||||
}
|
||||
|
||||
// POST /api/monitoring/enroll — Agent tauscht geteilten Bootstrap-Key gegen individuellen Per-Device-Key.
|
||||
// Idempotent: ein bereits enrollter Agent bekommt seinen bestehenden Key einfach erneut zurück.
|
||||
const enroll = asyncHandler(async (req, res) => {
|
||||
const agentKey = req.headers['x-agent-key'];
|
||||
const { hostname } = req.body;
|
||||
if (!hostname) return res.status(400).json({ status: 'error', message: 'hostname required' });
|
||||
|
||||
const db = getDatabase();
|
||||
const existing = db.prepare('SELECT id, agent_key FROM monitoring_agents WHERE hostname = ?').get(hostname);
|
||||
|
||||
const validBootstrap = agentKey === process.env.AGENT_API_KEY;
|
||||
const validExisting = existing?.agent_key && agentKey === existing.agent_key;
|
||||
if (!validBootstrap && !validExisting) {
|
||||
return res.status(401).json({ status: 'error', message: 'Invalid agent key' });
|
||||
}
|
||||
|
||||
let key = existing?.agent_key;
|
||||
if (!key) {
|
||||
key = 'itx-' + crypto.randomBytes(24).toString('hex');
|
||||
if (existing) {
|
||||
db.prepare('UPDATE monitoring_agents SET agent_key = ? WHERE id = ?').run(key, existing.id);
|
||||
} else {
|
||||
db.prepare('INSERT INTO monitoring_agents (hostname, agent_key) VALUES (?, ?)').run(hostname, key);
|
||||
}
|
||||
console.log(`[Enroll] Neuer Per-Device-Key ausgestellt für ${hostname}`);
|
||||
}
|
||||
res.json({ status: 'success', data: { agent_key: key } });
|
||||
});
|
||||
|
||||
function extractManufacturer(modelStr) {
|
||||
if (!modelStr) return null;
|
||||
@@ -75,14 +129,13 @@ function syncAgentToAsset(data) {
|
||||
// POST /api/monitoring/checkin — called by agent (no JWT, uses API key)
|
||||
const checkin = asyncHandler(async (req, res) => {
|
||||
const agentKey = req.headers['x-agent-key'];
|
||||
if (!agentKey || agentKey !== process.env.AGENT_API_KEY) {
|
||||
return res.status(401).json({ status: 'error', message: 'Invalid agent key' });
|
||||
}
|
||||
|
||||
const { hostname } = req.body;
|
||||
if (!hostname) {
|
||||
return res.status(400).json({ status: 'error', message: 'hostname required' });
|
||||
}
|
||||
if (!validateAgentKey(agentKey, hostname)) {
|
||||
return res.status(401).json({ status: 'error', message: 'Invalid agent key' });
|
||||
}
|
||||
|
||||
const agent = MonitoringAgent.upsert(req.body);
|
||||
|
||||
@@ -93,7 +146,7 @@ const checkin = asyncHandler(async (req, res) => {
|
||||
const { pending, running } = getPendingCommands(agent.id);
|
||||
|
||||
// Zielversion: Gruppen-spezifisch (staged rollout) oder globaler Default
|
||||
const GLOBAL_VERSION = process.env.AGENT_VERSION || '1.2.4';
|
||||
const GLOBAL_VERSION = process.env.AGENT_VERSION || FALLBACK_AGENT_VERSION;
|
||||
let targetVersion = GLOBAL_VERSION;
|
||||
try {
|
||||
const db = getDatabase();
|
||||
@@ -108,6 +161,8 @@ const checkin = asyncHandler(async (req, res) => {
|
||||
if (groupRow?.target_agent_version) targetVersion = groupRow.target_agent_version;
|
||||
} catch { /* kein Gruppe zugewiesen → global */ }
|
||||
|
||||
targetVersion = clampToNotOlder(targetVersion, req.body.agent_version);
|
||||
|
||||
const announcements = getForAgent(agent.id);
|
||||
|
||||
res.json({
|
||||
@@ -133,11 +188,11 @@ const getAll = asyncHandler(async (req, res) => {
|
||||
// POST /api/monitoring/announcements-poll — leichter Poll nur für Ankündigungen
|
||||
const announcementsPoll = asyncHandler(async (req, res) => {
|
||||
const agentKey = req.headers['x-agent-key'];
|
||||
if (!agentKey || agentKey !== process.env.AGENT_API_KEY) {
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
const { hostname } = req.body;
|
||||
if (!hostname) return res.status(400).json({ error: 'hostname required' });
|
||||
if (!validateAgentKey(agentKey, hostname)) {
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
const agent = MonitoringAgent.getByHostname(hostname);
|
||||
if (!agent) return res.json({ announcements: [] });
|
||||
const announcements = getForAgent(agent.id);
|
||||
@@ -181,7 +236,7 @@ const downloadScript = asyncHandler(async (req, res) => {
|
||||
return res.status(404).json({ status: 'error', message: 'Script nicht gefunden' });
|
||||
}
|
||||
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
||||
res.setHeader('X-Agent-Version', process.env.AGENT_VERSION || '1.2.3');
|
||||
res.setHeader('X-Agent-Version', process.env.AGENT_VERSION || FALLBACK_AGENT_VERSION);
|
||||
res.sendFile(scriptPath);
|
||||
});
|
||||
|
||||
@@ -204,7 +259,7 @@ const downloadAnnWatcher = asyncHandler(async (req, res) => {
|
||||
const downloadSetup = asyncHandler(async (req, res) => {
|
||||
// Agent-Key Auth für auto-update vom Agent selbst
|
||||
const agentKey = req.headers['x-agent-key'];
|
||||
const isAgent = agentKey && agentKey === process.env.AGENT_API_KEY;
|
||||
const isAgent = validateAgentKey(agentKey, req.query.hostname);
|
||||
const isAdmin = req.user?.role_name === 'admin' || req.user?.role_name === 'super_admin';
|
||||
if (!isAgent && !isAdmin) {
|
||||
return res.status(401).json({ status: 'error', message: 'Unauthorized' });
|
||||
@@ -214,12 +269,12 @@ const downloadSetup = asyncHandler(async (req, res) => {
|
||||
const fs = require('fs');
|
||||
|
||||
// Zielversion: Gruppen-spezifisch (staged rollout) oder globaler Default — gleiche Logik wie checkin()
|
||||
let version = process.env.AGENT_VERSION || '2.0.0';
|
||||
let version = process.env.AGENT_VERSION || FALLBACK_AGENT_VERSION;
|
||||
const hostname = req.query.hostname;
|
||||
if (hostname) {
|
||||
try {
|
||||
const db = getDatabase();
|
||||
const agent = db.prepare('SELECT id FROM monitoring_agents WHERE hostname = ?').get(hostname);
|
||||
const agent = db.prepare('SELECT id, agent_version FROM monitoring_agents WHERE hostname = ?').get(hostname);
|
||||
const groupRow = agent && db.prepare(`
|
||||
SELECT pg.target_agent_version
|
||||
FROM patch_agent_groups pag
|
||||
@@ -229,6 +284,7 @@ const downloadSetup = asyncHandler(async (req, res) => {
|
||||
LIMIT 1
|
||||
`).get(agent.id);
|
||||
if (groupRow?.target_agent_version) version = groupRow.target_agent_version;
|
||||
version = clampToNotOlder(version, agent?.agent_version);
|
||||
} catch { /* kein Gruppe zugewiesen → global */ }
|
||||
}
|
||||
|
||||
@@ -242,4 +298,4 @@ const downloadSetup = asyncHandler(async (req, res) => {
|
||||
res.sendFile(setupPath);
|
||||
});
|
||||
|
||||
module.exports = { checkin, announcementsPoll, getAll, getStatistics, getById, deleteAgent, downloadScript, downloadAnnWatcher, downloadSetup };
|
||||
module.exports = { checkin, announcementsPoll, getAll, getStatistics, getById, deleteAgent, downloadScript, downloadAnnWatcher, downloadSetup, enroll };
|
||||
|
||||
@@ -449,6 +449,8 @@ async function initializeDatabase() {
|
||||
`ALTER TABLE monitoring_agents ADD COLUMN defender_enabled INTEGER DEFAULT 0`,
|
||||
`ALTER TABLE monitoring_agents ADD COLUMN defender_signatures_age INTEGER DEFAULT -1`,
|
||||
`ALTER TABLE monitoring_agents ADD COLUMN hardware_serial TEXT`,
|
||||
// Pro-Geräte Agent-Key (Security-Migration weg vom geteilten AGENT_API_KEY)
|
||||
`ALTER TABLE monitoring_agents ADD COLUMN agent_key TEXT`,
|
||||
// Sichere Links (Shares)
|
||||
`CREATE TABLE IF NOT EXISTS shares (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
@@ -7,6 +7,9 @@ const { requireAdmin } = require('../middleware/roleCheck');
|
||||
// Agent checkin — no JWT, uses X-Agent-Key header
|
||||
router.post('/checkin', ctrl.checkin);
|
||||
|
||||
// Agent tauscht Bootstrap-Key gegen individuellen Per-Device-Key (Security-Migration)
|
||||
router.post('/enroll', ctrl.enroll);
|
||||
|
||||
// Schneller Announcement-Poll (leichtgewichtig, alle 15 Sek)
|
||||
router.post('/announcements-poll', ctrl.announcementsPoll);
|
||||
|
||||
|
||||
18
backend/src/utils/agentAuth.js
Normal file
18
backend/src/utils/agentAuth.js
Normal file
@@ -0,0 +1,18 @@
|
||||
const { getDatabase } = require('../config/database');
|
||||
|
||||
// Übergangslogik pro-Geräte-Keys (DSGVO/Security-Migration):
|
||||
// - Alte Agents (vor v2.8.0) schicken noch den einen geteilten AGENT_API_KEY → wird vorübergehend
|
||||
// weiter akzeptiert, damit der Rollout nicht die ganze Fleet auf einmal bricht.
|
||||
// - Migrierte Agents schicken ihren individuellen, beim Enrollment ausgestellten Key — dieser wird
|
||||
// NUR für den exakt zugehörigen Hostname akzeptiert, ein gestohlener Key kann also nicht mehr
|
||||
// benutzt werden, um sich als ein anderes Gerät auszugeben.
|
||||
function validateAgentKey(presentedKey, hostname) {
|
||||
if (!presentedKey) return false;
|
||||
if (presentedKey === process.env.AGENT_API_KEY) return true;
|
||||
if (!hostname) return false;
|
||||
const db = getDatabase();
|
||||
const row = db.prepare('SELECT agent_key FROM monitoring_agents WHERE hostname = ?').get(hostname);
|
||||
return !!row?.agent_key && row.agent_key === presentedKey;
|
||||
}
|
||||
|
||||
module.exports = { validateAgentKey };
|
||||
@@ -1,6 +1,7 @@
|
||||
const WebSocket = require('ws');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { getDatabase } = require('../config/database');
|
||||
const { validateAgentKey } = require('../utils/agentAuth');
|
||||
|
||||
// agentId -> WebSocket
|
||||
const agentSockets = new Map();
|
||||
@@ -43,12 +44,12 @@ function setupWebSocketServer(httpServer) {
|
||||
|
||||
function handleAgent(ws, url) {
|
||||
const key = url.searchParams.get('key');
|
||||
if (key !== process.env.AGENT_API_KEY) {
|
||||
const hostname = url.searchParams.get('hostname');
|
||||
if (!hostname) { ws.close(1008, 'hostname required'); return; }
|
||||
if (!validateAgentKey(key, hostname)) {
|
||||
ws.close(1008, 'unauthorized');
|
||||
return;
|
||||
}
|
||||
const hostname = url.searchParams.get('hostname');
|
||||
if (!hostname) { ws.close(1008, 'hostname required'); return; }
|
||||
|
||||
const db = getDatabase();
|
||||
const agent = db.prepare('SELECT id FROM monitoring_agents WHERE hostname = ?').get(hostname);
|
||||
@@ -143,9 +144,9 @@ function handleBrowser(ws, url) {
|
||||
|
||||
function handleRdpAgent(ws, url) {
|
||||
const key = url.searchParams.get('key');
|
||||
if (key !== process.env.AGENT_API_KEY) { ws.close(1008, 'unauthorized'); return; }
|
||||
const hostname = url.searchParams.get('hostname');
|
||||
if (!hostname) { ws.close(1008, 'hostname required'); return; }
|
||||
if (!validateAgentKey(key, hostname)) { ws.close(1008, 'unauthorized'); return; }
|
||||
|
||||
const db = getDatabase();
|
||||
const agent = db.prepare('SELECT id FROM monitoring_agents WHERE hostname = ?').get(hostname);
|
||||
|
||||
@@ -1,749 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Benutzerverwaltung – Design Demo</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--primary: #3fa3a3;
|
||||
--primary-dark: #008487;
|
||||
--bg: #0f172a;
|
||||
--bg2: #1e293b;
|
||||
--bg3: #334155;
|
||||
--text: #f1f5f9;
|
||||
--text2: #94a3b8;
|
||||
--text3: #64748b;
|
||||
--border: #334155;
|
||||
--success: #10b981;
|
||||
--danger: #ef4444;
|
||||
--warning: #f59e0b;
|
||||
--info: #3b82f6;
|
||||
--radius: 10px;
|
||||
--shadow: 0 4px 12px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
body { font-family: 'Inter', sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; }
|
||||
|
||||
/* ── Demo Selector Bar ── */
|
||||
.demo-bar {
|
||||
background: #0a1628;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 12px 24px;
|
||||
display: flex; align-items: center; gap: 16px;
|
||||
position: sticky; top: 0; z-index: 100;
|
||||
}
|
||||
.demo-bar-label { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: var(--text3); }
|
||||
.demo-tabs { display: flex; gap: 6px; }
|
||||
.demo-tab {
|
||||
padding: 6px 16px; border-radius: 20px; font-size: 13px; font-weight: 500;
|
||||
border: 1px solid var(--border); background: transparent; color: var(--text2);
|
||||
cursor: pointer; transition: all 0.15s;
|
||||
}
|
||||
.demo-tab:hover { border-color: var(--primary); color: var(--primary); }
|
||||
.demo-tab.active { background: var(--primary); border-color: var(--primary); color: #fff; }
|
||||
.demo-tab.recommended::after { content: ' ★'; font-size: 11px; }
|
||||
|
||||
/* ── Page Container ── */
|
||||
.page { display: none; padding: 28px 32px; max-width: 1300px; margin: 0 auto; }
|
||||
.page.visible { display: block; }
|
||||
|
||||
/* ── Page Header ── */
|
||||
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 24px; }
|
||||
.page-title { font-size: 22px; font-weight: 700; }
|
||||
.btn-group { display: flex; gap: 8px; }
|
||||
.btn {
|
||||
padding: 9px 18px; border-radius: 8px; font-size: 13px; font-weight: 500;
|
||||
border: none; cursor: pointer; transition: all 0.15s; font-family: inherit;
|
||||
}
|
||||
.btn-primary { background: var(--primary); color: #fff; }
|
||||
.btn-primary:hover { background: var(--primary-dark); }
|
||||
.btn-secondary { background: var(--bg3); color: var(--text); border: 1px solid var(--border); }
|
||||
.btn-secondary:hover { background: var(--bg2); border-color: var(--primary); }
|
||||
|
||||
/* ── Stats Cards ── */
|
||||
.stats-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin-bottom: 20px; }
|
||||
.stat-card {
|
||||
background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius);
|
||||
padding: 16px 20px; display: flex; align-items: center; gap: 14px;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.stat-card:hover { border-color: var(--primary); }
|
||||
.stat-icon {
|
||||
width: 42px; height: 42px; border-radius: 10px; display: flex; align-items: center;
|
||||
justify-content: center; font-size: 18px; flex-shrink: 0;
|
||||
}
|
||||
.stat-icon.blue { background: rgba(59,130,246,0.15); }
|
||||
.stat-icon.green { background: rgba(16,185,129,0.15); }
|
||||
.stat-icon.red { background: rgba(239,68,68,0.15); }
|
||||
.stat-icon.yellow { background: rgba(245,158,11,0.15); }
|
||||
.stat-num { font-size: 24px; font-weight: 700; line-height: 1; }
|
||||
.stat-label { font-size: 12px; color: var(--text2); margin-top: 3px; }
|
||||
|
||||
/* ── Filter Bar ── */
|
||||
.filter-bar { display: flex; gap: 10px; align-items: center; margin-bottom: 16px; flex-wrap: wrap; }
|
||||
.search-input {
|
||||
background: var(--bg2); border: 1px solid var(--border); color: var(--text);
|
||||
border-radius: 8px; padding: 9px 14px; font-size: 13px; font-family: inherit;
|
||||
outline: none; width: 280px; transition: border-color 0.15s;
|
||||
}
|
||||
.search-input:focus { border-color: var(--primary); }
|
||||
.filter-chips { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.chip {
|
||||
padding: 5px 12px; border-radius: 20px; font-size: 12px; font-weight: 500;
|
||||
border: 1px solid var(--border); background: var(--bg2); color: var(--text2);
|
||||
cursor: pointer; transition: all 0.12s; white-space: nowrap;
|
||||
}
|
||||
.chip:hover { border-color: var(--primary); color: var(--primary); }
|
||||
.chip.active { background: var(--primary); border-color: var(--primary); color: #fff; }
|
||||
.chip.active-red { background: var(--danger); border-color: var(--danger); color: #fff; }
|
||||
.chip.active-green { background: var(--success); border-color: var(--success); color: #fff; }
|
||||
.chip-sep { width: 1px; height: 24px; background: var(--border); margin: 0 2px; }
|
||||
|
||||
/* ── Table ── */
|
||||
.card { background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
thead th {
|
||||
background: var(--bg3); text-align: left; padding: 11px 16px;
|
||||
font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.8px;
|
||||
color: var(--text3); border-bottom: 1px solid var(--border); white-space: nowrap;
|
||||
}
|
||||
tbody tr { border-bottom: 1px solid rgba(51,65,85,0.5); transition: background 0.12s; }
|
||||
tbody tr:last-child { border-bottom: none; }
|
||||
tbody tr:hover { background: rgba(63,163,163,0.04); }
|
||||
td { padding: 12px 16px; font-size: 13px; vertical-align: middle; }
|
||||
|
||||
/* ── Avatar ── */
|
||||
.user-cell { display: flex; align-items: center; gap: 10px; }
|
||||
.avatar {
|
||||
width: 34px; height: 34px; border-radius: 50%; display: flex; align-items: center;
|
||||
justify-content: center; font-size: 12px; font-weight: 700; flex-shrink: 0;
|
||||
color: #fff;
|
||||
}
|
||||
.user-name { font-weight: 500; font-size: 13px; }
|
||||
.user-username { font-size: 11px; color: var(--text3); }
|
||||
|
||||
/* ── Badges ── */
|
||||
.badge {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
padding: 3px 10px; border-radius: 20px; font-size: 11px; font-weight: 600;
|
||||
text-transform: uppercase; letter-spacing: 0.4px;
|
||||
}
|
||||
.badge-dot { width: 6px; height: 6px; border-radius: 50%; }
|
||||
.badge-active { background: rgba(16,185,129,0.15); color: var(--success); }
|
||||
.badge-inactive { background: rgba(239,68,68,0.1); color: var(--danger); }
|
||||
.role-badge {
|
||||
padding: 3px 9px; border-radius: 6px; font-size: 11px; font-weight: 600; display: inline-flex; align-items: center; gap: 4px;
|
||||
}
|
||||
|
||||
/* ── Action buttons (old style for Option A) ── */
|
||||
.actions-old { display: flex; gap: 6px; }
|
||||
.btn-sm { padding: 5px 12px; border-radius: 6px; font-size: 11px; font-weight: 600; border: none; cursor: pointer; font-family: inherit; }
|
||||
.btn-sm-edit { background: rgba(63,163,163,0.15); color: var(--primary); }
|
||||
.btn-sm-edit:hover { background: rgba(63,163,163,0.25); }
|
||||
.btn-sm-toggle { background: rgba(148,163,184,0.1); color: var(--text2); }
|
||||
.btn-sm-toggle:hover { background: rgba(148,163,184,0.2); }
|
||||
.btn-sm-del { background: rgba(239,68,68,0.1); color: var(--danger); }
|
||||
.btn-sm-del:hover { background: rgba(239,68,68,0.2); }
|
||||
|
||||
/* ── 3-dot menu (Option B+C) ── */
|
||||
.menu-wrap { position: relative; display: inline-block; }
|
||||
.menu-btn {
|
||||
width: 30px; height: 30px; border-radius: 8px; border: 1px solid var(--border);
|
||||
background: transparent; color: var(--text2); cursor: pointer; font-size: 18px;
|
||||
display: flex; align-items: center; justify-content: center; transition: all 0.12s;
|
||||
line-height: 1;
|
||||
}
|
||||
.menu-btn:hover { background: var(--bg3); color: var(--text); border-color: var(--primary); }
|
||||
.dropdown {
|
||||
position: absolute; right: 0; top: calc(100% + 4px); background: var(--bg2);
|
||||
border: 1px solid var(--border); border-radius: 8px; min-width: 160px;
|
||||
box-shadow: var(--shadow); z-index: 50; overflow: hidden; display: none;
|
||||
}
|
||||
.dropdown.open { display: block; }
|
||||
.dropdown-item {
|
||||
padding: 9px 14px; font-size: 13px; cursor: pointer; display: flex; align-items: center; gap: 8px;
|
||||
transition: background 0.1s; color: var(--text);
|
||||
}
|
||||
.dropdown-item:hover { background: var(--bg3); }
|
||||
.dropdown-item.danger { color: var(--danger); }
|
||||
.dropdown-item.danger:hover { background: rgba(239,68,68,0.1); }
|
||||
.dropdown-sep { height: 1px; background: var(--border); margin: 3px 0; }
|
||||
|
||||
/* Inline role dropdown */
|
||||
.role-select {
|
||||
background: var(--bg3); border: 1px solid var(--border); color: var(--text);
|
||||
border-radius: 6px; padding: 4px 8px; font-size: 12px; font-family: inherit;
|
||||
cursor: pointer; outline: none; transition: border-color 0.12s;
|
||||
}
|
||||
.role-select:focus { border-color: var(--primary); }
|
||||
|
||||
/* ── Relative time ── */
|
||||
.time-abs { font-size: 12px; color: var(--text2); }
|
||||
.time-rel { font-size: 11px; color: var(--text3); }
|
||||
.time-never { color: var(--text3); font-style: italic; }
|
||||
|
||||
/* ── Slideover (Option C) ── */
|
||||
.slideover-layout { display: flex; gap: 0; position: relative; }
|
||||
.table-area { flex: 1; min-width: 0; transition: margin-right 0.3s; }
|
||||
.table-area.panel-open { margin-right: 0; }
|
||||
.slideover {
|
||||
width: 360px; flex-shrink: 0; background: var(--bg2); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); margin-left: 16px;
|
||||
animation: slideIn 0.25s ease;
|
||||
overflow-y: auto; max-height: calc(100vh - 200px);
|
||||
}
|
||||
@keyframes slideIn { from { opacity: 0; transform: translateX(30px); } to { opacity: 1; transform: translateX(0); } }
|
||||
|
||||
.panel-header { padding: 16px 20px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 12px; }
|
||||
.panel-avatar { width: 48px; height: 48px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 18px; font-weight: 700; color: #fff; flex-shrink: 0; }
|
||||
.panel-name { font-weight: 700; font-size: 15px; }
|
||||
.panel-email { font-size: 12px; color: var(--text2); }
|
||||
.panel-close { margin-left: auto; background: none; border: none; color: var(--text2); font-size: 20px; cursor: pointer; padding: 4px; border-radius: 6px; }
|
||||
.panel-close:hover { background: var(--bg3); color: var(--text); }
|
||||
|
||||
.panel-section { padding: 16px 20px; border-bottom: 1px solid var(--border); }
|
||||
.panel-section:last-child { border-bottom: none; }
|
||||
.panel-section-label { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: var(--text3); margin-bottom: 12px; }
|
||||
.panel-field { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; font-size: 13px; }
|
||||
.panel-field:last-child { margin-bottom: 0; }
|
||||
.panel-field-label { color: var(--text2); }
|
||||
.panel-actions { padding: 14px 20px; display: flex; gap: 8px; flex-direction: column; }
|
||||
.btn-full { width: 100%; padding: 9px; text-align: center; border-radius: 8px; font-size: 13px; font-weight: 500; cursor: pointer; border: none; font-family: inherit; transition: all 0.12s; }
|
||||
.btn-full-primary { background: var(--primary); color: #fff; }
|
||||
.btn-full-primary:hover { background: var(--primary-dark); }
|
||||
.btn-full-warn { background: rgba(245,158,11,0.12); color: var(--warning); border: 1px solid rgba(245,158,11,0.3); }
|
||||
.btn-full-warn:hover { background: rgba(245,158,11,0.2); }
|
||||
.btn-full-danger { background: rgba(239,68,68,0.1); color: var(--danger); border: 1px solid rgba(239,68,68,0.25); }
|
||||
.btn-full-danger:hover { background: rgba(239,68,68,0.18); }
|
||||
|
||||
/* Row clickable for C */
|
||||
.clickable-row { cursor: pointer; }
|
||||
.clickable-row.selected-row { background: rgba(63,163,163,0.08) !important; }
|
||||
.clickable-row.selected-row td { border-left: 2px solid var(--primary); }
|
||||
.clickable-row.selected-row td:not(:first-child) { border-left: none; }
|
||||
|
||||
/* ── Last login ── */
|
||||
td .last-login { display: flex; flex-direction: column; }
|
||||
|
||||
/* ── Tooltip style note ── */
|
||||
.design-note {
|
||||
background: rgba(63,163,163,0.08); border: 1px solid rgba(63,163,163,0.2);
|
||||
border-radius: 8px; padding: 10px 16px; margin-bottom: 16px;
|
||||
font-size: 12px; color: var(--text2); line-height: 1.6;
|
||||
}
|
||||
.design-note strong { color: var(--primary); }
|
||||
|
||||
.combo-badge {
|
||||
background: var(--primary); color: #fff; font-size: 10px; font-weight: 700;
|
||||
padding: 2px 8px; border-radius: 20px; margin-left: 8px; text-transform: uppercase;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Demo Switcher Bar -->
|
||||
<div class="demo-bar">
|
||||
<span class="demo-bar-label">Design-Option:</span>
|
||||
<div class="demo-tabs">
|
||||
<button class="demo-tab" onclick="show('a')">A – Stats + Filter-Chips</button>
|
||||
<button class="demo-tab" onclick="show('b')">B – Avatar + 3-Punkte-Menü</button>
|
||||
<button class="demo-tab" onclick="show('c')">C – Slideover-Panel</button>
|
||||
<button class="demo-tab recommended active" onclick="show('combo')">Kombiniert (Empfehlung)</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════════════════════
|
||||
OPTION A – Stats + Filter Chips
|
||||
════════════════════════════════════════════════════════ -->
|
||||
<div id="page-a" class="page">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Benutzerverwaltung</h1>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-secondary">☁️ Aus Azure importieren</button>
|
||||
<button class="btn btn-primary">+ Neuer Benutzer</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="design-note">
|
||||
<strong>Option A:</strong> Statistik-Kacheln oben + Filter-Chips statt nur Textsuche. Tabelle bleibt gleich. Kleinster Aufwand, sofortiger Informationsgewinn.
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="stats-row">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon blue">👥</div>
|
||||
<div><div class="stat-num">14</div><div class="stat-label">Benutzer gesamt</div></div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon green">✅</div>
|
||||
<div><div class="stat-num">11</div><div class="stat-label">Aktiv</div></div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon red">🚫</div>
|
||||
<div><div class="stat-num">3</div><div class="stat-label">Deaktiviert</div></div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon yellow">⏱️</div>
|
||||
<div><div class="stat-num">4</div><div class="stat-label">Noch nie eingeloggt</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Bar -->
|
||||
<div class="filter-bar">
|
||||
<input class="search-input" type="text" placeholder="🔍 Suche nach Name, E-Mail, Rolle…" oninput="filterA(this.value)">
|
||||
<div class="chip-sep"></div>
|
||||
<div class="filter-chips" id="role-chips-a">
|
||||
<span class="chip active" onclick="chipClick(this,'a')">Alle Rollen</span>
|
||||
<span class="chip" onclick="chipClick(this,'a')">👑 Super Admin</span>
|
||||
<span class="chip" onclick="chipClick(this,'a')">🛡️ Admin</span>
|
||||
<span class="chip" onclick="chipClick(this,'a')">🎧 Support</span>
|
||||
<span class="chip" onclick="chipClick(this,'a')">🔧 Bearbeiter</span>
|
||||
<span class="chip" onclick="chipClick(this,'a')">👤 Benutzer</span>
|
||||
</div>
|
||||
<div class="chip-sep"></div>
|
||||
<span class="chip active-green active" onclick="statusChip(this,'aktiv')">● Aktiv</span>
|
||||
<span class="chip" onclick="statusChip(this,'inaktiv')">● Inaktiv</span>
|
||||
</div>
|
||||
|
||||
<!-- Table (same as current, just with search) -->
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Benutzername</th>
|
||||
<th>E-Mail</th>
|
||||
<th>Name</th>
|
||||
<th>Rolle</th>
|
||||
<th>Status</th>
|
||||
<th>Letzter Login</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tbody-a"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════════════════════
|
||||
OPTION B – Avatar + 3-Punkte-Menü
|
||||
════════════════════════════════════════════════════════ -->
|
||||
<div id="page-b" class="page">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Benutzerverwaltung</h1>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-secondary">☁️ Aus Azure importieren</button>
|
||||
<button class="btn btn-primary">+ Neuer Benutzer</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="design-note">
|
||||
<strong>Option B:</strong> Avatar-Initialen in der ersten Spalte + Aktionen als 3-Punkte-Menü (spart viel Platz, besonders bei langen Buttons). Rolle inline per Dropdown änderbar.
|
||||
</div>
|
||||
|
||||
<div class="filter-bar">
|
||||
<input class="search-input" type="text" placeholder="🔍 Suche nach Name, E-Mail, Rolle…">
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Benutzer</th>
|
||||
<th>E-Mail</th>
|
||||
<th>Rolle</th>
|
||||
<th>Status</th>
|
||||
<th>Letzter Login</th>
|
||||
<th style="width:50px">···</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tbody-b"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════════════════════
|
||||
OPTION C – Slideover-Panel
|
||||
════════════════════════════════════════════════════════ -->
|
||||
<div id="page-c" class="page">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Benutzerverwaltung</h1>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-secondary">☁️ Aus Azure importieren</button>
|
||||
<button class="btn btn-primary">+ Neuer Benutzer</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="design-note">
|
||||
<strong>Option C:</strong> Klick auf eine Zeile öffnet ein Detailpanel rechts. Tabelle bleibt links sichtbar. Kein Modal-Overlay. → <em>Klick auf eine Zeile zum Testen!</em>
|
||||
</div>
|
||||
|
||||
<div class="filter-bar">
|
||||
<input class="search-input" type="text" placeholder="🔍 Suche nach Name, E-Mail, Rolle…">
|
||||
</div>
|
||||
|
||||
<div class="slideover-layout" id="c-layout">
|
||||
<div class="table-area card" id="c-table-area">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Benutzer</th>
|
||||
<th>E-Mail</th>
|
||||
<th>Rolle</th>
|
||||
<th>Status</th>
|
||||
<th>Letzter Login</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tbody-c"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="slideover" id="c-panel" style="display:none"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════════════════════
|
||||
KOMBINATION (Empfehlung): Stats + Filter + Avatar + 3-Punkte + Slideover
|
||||
════════════════════════════════════════════════════════ -->
|
||||
<div id="page-combo" class="page visible">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">Benutzerverwaltung <span class="combo-badge">Empfehlung</span></h1>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-secondary">☁️ Aus Azure importieren</button>
|
||||
<button class="btn btn-primary">+ Neuer Benutzer</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="design-note">
|
||||
<strong>Kombination aus A + B + C:</strong> Stats-Kacheln · Filter-Chips · Avatar-Initialen · 3-Punkte-Menü · Slideover-Panel bei Klick auf eine Zeile. → <em>Klick auf eine Zeile öffnet das Detail-Panel!</em>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="stats-row">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon blue">👥</div>
|
||||
<div><div class="stat-num">14</div><div class="stat-label">Benutzer gesamt</div></div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon green">✅</div>
|
||||
<div><div class="stat-num">11</div><div class="stat-label">Aktiv</div></div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon red">🚫</div>
|
||||
<div><div class="stat-num">3</div><div class="stat-label">Deaktiviert</div></div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon yellow">⏱️</div>
|
||||
<div><div class="stat-num">4</div><div class="stat-label">Noch nie eingeloggt</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter -->
|
||||
<div class="filter-bar">
|
||||
<input class="search-input" type="text" placeholder="🔍 Suche nach Name, E-Mail, Rolle…">
|
||||
<div class="chip-sep"></div>
|
||||
<div class="filter-chips">
|
||||
<span class="chip active" onclick="chipClick(this,'combo')">Alle Rollen</span>
|
||||
<span class="chip" onclick="chipClick(this,'combo')">👑 Super Admin</span>
|
||||
<span class="chip" onclick="chipClick(this,'combo')">🛡️ Admin</span>
|
||||
<span class="chip" onclick="chipClick(this,'combo')">🎧 Support</span>
|
||||
<span class="chip" onclick="chipClick(this,'combo')">👤 Benutzer</span>
|
||||
</div>
|
||||
<div class="chip-sep"></div>
|
||||
<span class="chip active-green active">● Aktiv</span>
|
||||
<span class="chip">● Inaktiv</span>
|
||||
</div>
|
||||
|
||||
<div class="slideover-layout" id="combo-layout">
|
||||
<div class="table-area card" id="combo-table-area">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Benutzer</th>
|
||||
<th>E-Mail</th>
|
||||
<th>Rolle</th>
|
||||
<th>Status</th>
|
||||
<th>Letzter Login</th>
|
||||
<th style="width:50px">···</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tbody-combo"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="slideover" id="combo-panel" style="display:none"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ── Mock Data ──
|
||||
const ROLE_META = {
|
||||
super_admin: { icon: '👑', color: '#ef4444', label: 'Super Admin', avatarBg: '#7f1d1d' },
|
||||
admin: { icon: '🛡️', color: '#f97316', label: 'Admin', avatarBg: '#7c2d12' },
|
||||
support: { icon: '🎧', color: '#3b82f6', label: 'IT-Support', avatarBg: '#1e3a8a' },
|
||||
bearbeiter: { icon: '🔧', color: '#8b5cf6', label: 'Bearbeiter', avatarBg: '#4c1d95' },
|
||||
benutzer: { icon: '👤', color: '#64748b', label: 'Benutzer', avatarBg: '#1e293b' },
|
||||
hr_personal: { icon: '🧑💼', color: '#10b981', label: 'HR', avatarBg: '#064e3b' },
|
||||
buchhaltung: { icon: '💶', color: '#a78bfa', label: 'Buchhaltung', avatarBg: '#3b0764' },
|
||||
};
|
||||
|
||||
const AVATAR_COLORS = ['#1d4ed8','#0f766e','#b45309','#be185d','#7c3aed','#0369a1','#15803d','#c2410c'];
|
||||
function avatarColor(name) {
|
||||
let h = 0; for (let c of name) h = (h * 31 + c.charCodeAt(0)) & 0xffff;
|
||||
return AVATAR_COLORS[h % AVATAR_COLORS.length];
|
||||
}
|
||||
function initials(u) {
|
||||
if (u.first_name && u.last_name) return (u.first_name[0]+u.last_name[0]).toUpperCase();
|
||||
return u.username.slice(0,2).toUpperCase();
|
||||
}
|
||||
|
||||
const USERS = [
|
||||
{ id:1, username:'gruessing', email:'gruessing@cereda-systems.de', first_name:'Simon', last_name:'Grüssing', role:'super_admin', active:true, last_login:'2026-05-27T14:32:00' },
|
||||
{ id:2, username:'smueller', email:'smueller@cereda-systems.de', first_name:'Stefan', last_name:'Müller', role:'admin', active:true, last_login:'2026-05-26T09:11:00' },
|
||||
{ id:3, username:'jschmidt', email:'jschmidt@cereda-systems.de', first_name:'Julia', last_name:'Schmidt', role:'support', active:true, last_login:'2026-05-27T08:45:00' },
|
||||
{ id:4, username:'tbauer', email:'tbauer@cereda-systems.de', first_name:'Thomas', last_name:'Bauer', role:'support', active:true, last_login:'2026-05-25T16:20:00' },
|
||||
{ id:5, username:'mweber', email:'mweber@cereda-systems.de', first_name:'Maria', last_name:'Weber', role:'bearbeiter', active:true, last_login:'2026-05-22T11:00:00' },
|
||||
{ id:6, username:'fkoch', email:'fkoch@cereda-systems.de', first_name:'Felix', last_name:'Koch', role:'benutzer', active:true, last_login:'2026-05-20T09:00:00' },
|
||||
{ id:7, username:'krichter', email:'krichter@cereda-systems.de', first_name:'Klaus', last_name:'Richter', role:'benutzer', active:true, last_login:'2026-05-15T14:00:00' },
|
||||
{ id:8, username:'aschneider', email:'aschneider@cereda-systems.de', first_name:'Anna', last_name:'Schneider', role:'hr_personal', active:true, last_login:'2026-05-24T10:30:00' },
|
||||
{ id:9, username:'benders', email:'benders@cereda-systems.de', first_name:'Björn', last_name:'Enders', role:'benutzer', active:true, last_login:'2026-05-19T07:55:00' },
|
||||
{ id:10, username:'mfischer', email:'mfischer@cereda-systems.de', first_name:'Markus', last_name:'Fischer', role:'buchhaltung', active:true, last_login:'2026-05-23T13:00:00' },
|
||||
{ id:11, username:'lbraun', email:'lbraun@cereda-systems.de', first_name:'Lena', last_name:'Braun', role:'benutzer', active:true, last_login:null },
|
||||
{ id:12, username:'konferenzraum', email:'konferenzraum@cereda-systems.de', first_name:null, last_name:null, role:'benutzer', active:true, last_login:null },
|
||||
{ id:13, username:'poolfahrzeug', email:'poolfahrzeug@cereda-systems.de', first_name:null, last_name:null, role:'benutzer', active:false, last_login:null },
|
||||
{ id:14, username:'einkauf', email:'einkauf@cereda-systems.de', first_name:null, last_name:null, role:'benutzer', active:false, last_login:null },
|
||||
];
|
||||
|
||||
function relativeTime(iso) {
|
||||
if (!iso) return '<span class="time-never">Nie</span>';
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const mins = Math.floor(diff/60000), hrs = Math.floor(mins/60), days = Math.floor(hrs/24);
|
||||
let rel = days > 0 ? `vor ${days} Tag${days>1?'en':''}` : hrs > 0 ? `vor ${hrs} Std.` : mins > 0 ? `vor ${mins} Min.` : 'gerade eben';
|
||||
const abs = new Date(iso).toLocaleDateString('de-DE', {day:'2-digit',month:'2-digit',year:'numeric'});
|
||||
return `<div class="last-login"><span class="time-abs">${abs}</span><span class="time-rel">${rel}</span></div>`;
|
||||
}
|
||||
|
||||
function roleBadge(role) {
|
||||
const m = ROLE_META[role] || { icon:'🔵', color:'#64748b', label: role };
|
||||
return `<span class="role-badge" style="background:${m.color}18;color:${m.color};">${m.icon} ${m.label}</span>`;
|
||||
}
|
||||
|
||||
function statusBadge(active) {
|
||||
return active
|
||||
? `<span class="badge badge-active"><span class="badge-dot" style="background:var(--success)"></span>Aktiv</span>`
|
||||
: `<span class="badge badge-inactive"><span class="badge-dot" style="background:var(--danger)"></span>Inaktiv</span>`;
|
||||
}
|
||||
|
||||
function avatarHtml(u, size=34, fontSize=12) {
|
||||
const bg = avatarColor(u.username);
|
||||
const ini = initials(u);
|
||||
return `<div class="avatar" style="width:${size}px;height:${size}px;background:${bg};font-size:${fontSize}px">${ini}</div>`;
|
||||
}
|
||||
|
||||
// ── Option A ──
|
||||
function renderA() {
|
||||
const rows = USERS.map(u => `
|
||||
<tr>
|
||||
<td>${u.username}</td>
|
||||
<td style="color:var(--text2);font-size:12px">${u.email}</td>
|
||||
<td>${u.first_name || u.last_name ? (u.first_name||'') + ' ' + (u.last_name||'') : '–'}</td>
|
||||
<td>${roleBadge(u.role)}</td>
|
||||
<td>${statusBadge(u.active)}</td>
|
||||
<td>${relativeTime(u.last_login)}</td>
|
||||
<td>
|
||||
<div class="actions-old">
|
||||
<button class="btn-sm btn-sm-edit">Bearbeiten</button>
|
||||
<button class="btn-sm btn-sm-toggle">${u.active?'Deaktivieren':'Aktivieren'}</button>
|
||||
<button class="btn-sm btn-sm-del">Löschen</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
document.getElementById('tbody-a').innerHTML = rows;
|
||||
}
|
||||
|
||||
// ── Option B ──
|
||||
function renderB() {
|
||||
const rows = USERS.map(u => `
|
||||
<tr>
|
||||
<td>
|
||||
<div class="user-cell">
|
||||
${avatarHtml(u)}
|
||||
<div>
|
||||
<div class="user-name">${u.first_name || u.last_name ? (u.first_name||'')+' '+(u.last_name||'') : u.username}</div>
|
||||
<div class="user-username">@${u.username}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td style="color:var(--text2);font-size:12px">${u.email}</td>
|
||||
<td>
|
||||
<select class="role-select" title="Rolle direkt ändern">
|
||||
${Object.entries(ROLE_META).map(([k,m]) => `<option value="${k}" ${k===u.role?'selected':''}>${m.icon} ${m.label}</option>`).join('')}
|
||||
</select>
|
||||
</td>
|
||||
<td>${statusBadge(u.active)}</td>
|
||||
<td>${relativeTime(u.last_login)}</td>
|
||||
<td>
|
||||
<div class="menu-wrap" onclick="event.stopPropagation()">
|
||||
<button class="menu-btn" onclick="toggleMenu(this)">⋯</button>
|
||||
<div class="dropdown">
|
||||
<div class="dropdown-item">✏️ Bearbeiten</div>
|
||||
<div class="dropdown-item">${u.active ? '🚫 Deaktivieren' : '✅ Aktivieren'}</div>
|
||||
<div class="dropdown-item">🔑 Passwort zurücksetzen</div>
|
||||
<div class="dropdown-sep"></div>
|
||||
<div class="dropdown-item danger">🗑️ Löschen</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
document.getElementById('tbody-b').innerHTML = rows;
|
||||
}
|
||||
|
||||
// ── Option C – Slideover ──
|
||||
let selectedC = null;
|
||||
function renderC() {
|
||||
const rows = USERS.map((u,i) => `
|
||||
<tr class="clickable-row" id="crow-${i}" onclick="openPanel('c', ${i})">
|
||||
<td>
|
||||
<div class="user-cell">
|
||||
${avatarHtml(u)}
|
||||
<div>
|
||||
<div class="user-name">${u.first_name || u.last_name ? (u.first_name||'')+' '+(u.last_name||'') : u.username}</div>
|
||||
<div class="user-username">@${u.username}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td style="color:var(--text2);font-size:12px">${u.email}</td>
|
||||
<td>${roleBadge(u.role)}</td>
|
||||
<td>${statusBadge(u.active)}</td>
|
||||
<td>${relativeTime(u.last_login)}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
document.getElementById('tbody-c').innerHTML = rows;
|
||||
}
|
||||
|
||||
// ── Combo ──
|
||||
let selectedCombo = null;
|
||||
function renderCombo() {
|
||||
const rows = USERS.map((u,i) => `
|
||||
<tr class="clickable-row" id="combrow-${i}" onclick="openPanel('combo', ${i})">
|
||||
<td>
|
||||
<div class="user-cell">
|
||||
${avatarHtml(u)}
|
||||
<div>
|
||||
<div class="user-name">${u.first_name || u.last_name ? (u.first_name||'')+' '+(u.last_name||'') : u.username}</div>
|
||||
<div class="user-username">@${u.username}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td style="color:var(--text2);font-size:12px">${u.email}</td>
|
||||
<td>
|
||||
<select class="role-select" onclick="event.stopPropagation()" title="Rolle direkt ändern">
|
||||
${Object.entries(ROLE_META).map(([k,m]) => `<option value="${k}" ${k===u.role?'selected':''}>${m.icon} ${m.label}</option>`).join('')}
|
||||
</select>
|
||||
</td>
|
||||
<td>${statusBadge(u.active)}</td>
|
||||
<td>${relativeTime(u.last_login)}</td>
|
||||
<td>
|
||||
<div class="menu-wrap" onclick="event.stopPropagation()">
|
||||
<button class="menu-btn" onclick="toggleMenu(this)">⋯</button>
|
||||
<div class="dropdown">
|
||||
<div class="dropdown-item">✏️ Bearbeiten</div>
|
||||
<div class="dropdown-item">${u.active ? '🚫 Deaktivieren' : '✅ Aktivieren'}</div>
|
||||
<div class="dropdown-item">🔑 Passwort zurücksetzen</div>
|
||||
<div class="dropdown-sep"></div>
|
||||
<div class="dropdown-item danger">🗑️ Löschen</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
document.getElementById('tbody-combo').innerHTML = rows;
|
||||
}
|
||||
|
||||
// ── Panel Content ──
|
||||
function panelHtml(u, prefix) {
|
||||
const m = ROLE_META[u.role] || {};
|
||||
const displayName = u.first_name || u.last_name ? `${u.first_name||''} ${u.last_name||''}`.trim() : u.username;
|
||||
return `
|
||||
<div class="panel-header">
|
||||
${avatarHtml(u, 48, 18)}
|
||||
<div>
|
||||
<div class="panel-name">${displayName}</div>
|
||||
<div class="panel-email">${u.email}</div>
|
||||
</div>
|
||||
<button class="panel-close" onclick="closePanel('${prefix}')">×</button>
|
||||
</div>
|
||||
<div class="panel-section">
|
||||
<div class="panel-section-label">Account</div>
|
||||
<div class="panel-field"><span class="panel-field-label">Benutzername</span><span>@${u.username}</span></div>
|
||||
<div class="panel-field"><span class="panel-field-label">Rolle</span>${roleBadge(u.role)}</div>
|
||||
<div class="panel-field"><span class="panel-field-label">Status</span>${statusBadge(u.active)}</div>
|
||||
</div>
|
||||
<div class="panel-section">
|
||||
<div class="panel-section-label">Aktivität</div>
|
||||
<div class="panel-field"><span class="panel-field-label">Letzter Login</span>${u.last_login ? new Date(u.last_login).toLocaleString('de-DE') : '<span style="color:var(--text3);font-style:italic">Noch nie</span>'}</div>
|
||||
<div class="panel-field"><span class="panel-field-label">Konto-ID</span><span style="color:var(--text3)">#${u.id}</span></div>
|
||||
</div>
|
||||
<div class="panel-actions">
|
||||
<button class="btn-full btn-full-primary">✏️ Bearbeiten</button>
|
||||
<button class="btn-full btn-full-warn">${u.active ? '🚫 Deaktivieren' : '✅ Aktivieren'}</button>
|
||||
<button class="btn-full btn-full-danger">🗑️ Löschen</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function openPanel(prefix, idx) {
|
||||
const u = USERS[idx];
|
||||
const panel = document.getElementById(`${prefix}-panel`);
|
||||
panel.innerHTML = panelHtml(u, prefix);
|
||||
panel.style.display = 'block';
|
||||
|
||||
// highlight row
|
||||
document.querySelectorAll(`#tbody-${prefix} tr`).forEach((r,i) => r.classList.toggle('selected-row', i===idx));
|
||||
}
|
||||
|
||||
function closePanel(prefix) {
|
||||
document.getElementById(`${prefix}-panel`).style.display = 'none';
|
||||
document.querySelectorAll(`#tbody-${prefix} tr`).forEach(r => r.classList.remove('selected-row'));
|
||||
}
|
||||
|
||||
// ── Menu toggle ──
|
||||
function toggleMenu(btn) {
|
||||
const dd = btn.nextElementSibling;
|
||||
const wasOpen = dd.classList.contains('open');
|
||||
// close all
|
||||
document.querySelectorAll('.dropdown.open').forEach(d => d.classList.remove('open'));
|
||||
if (!wasOpen) dd.classList.add('open');
|
||||
}
|
||||
document.addEventListener('click', () => {
|
||||
document.querySelectorAll('.dropdown.open').forEach(d => d.classList.remove('open'));
|
||||
});
|
||||
|
||||
// ── Filter chips ──
|
||||
function chipClick(el, page) {
|
||||
el.closest('.filter-chips').querySelectorAll('.chip').forEach(c => c.classList.remove('active'));
|
||||
el.classList.add('active');
|
||||
}
|
||||
|
||||
// ── Page switcher ──
|
||||
function show(id) {
|
||||
document.querySelectorAll('.page').forEach(p => p.classList.remove('visible'));
|
||||
document.getElementById('page-' + id).classList.add('visible');
|
||||
document.querySelectorAll('.demo-tab').forEach(t => t.classList.remove('active'));
|
||||
event.target.classList.add('active');
|
||||
}
|
||||
|
||||
// ── Init ──
|
||||
renderA();
|
||||
renderB();
|
||||
renderC();
|
||||
renderCombo();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
997
devteam.js
997
devteam.js
@@ -1,997 +0,0 @@
|
||||
// IT Nexus Dev Team — Lokaler KI-Entwickler-Agent
|
||||
// Start: node devteam.js
|
||||
// Dann: http://localhost:4242
|
||||
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
|
||||
const API_KEY = process.env.ANTHROPIC_API_KEY || '';
|
||||
const PORT = 4242;
|
||||
|
||||
// ─── IT Nexus Kontext für alle Agenten ────────────────────────────────────────
|
||||
const IT_NEXUS_CONTEXT = `
|
||||
# IT Nexus – Vollständiger Stack-Kontext
|
||||
|
||||
## Tech Stack
|
||||
- Backend: Node.js 18 + Express 4 + SQLite (better-sqlite3, SYNCHRON - kein await!)
|
||||
- Frontend: React 18 (Create React App) + Custom CSS (Glassmorphism)
|
||||
- Auth: JWT (jsonwebtoken) + bcryptjs
|
||||
- KI: @anthropic-ai/sdk (Claude claude-sonnet-4-6)
|
||||
- Deployment: Docker Compose + nginx auf LXC CT 102 (192.168.0.194)
|
||||
|
||||
## Dateistruktur Backend
|
||||
\`\`\`
|
||||
backend/src/
|
||||
server.js – Express Setup, alle app.use() Registrierungen
|
||||
db/seed.js – Alle DB-Migrations (jede in try/catch, idempotent!)
|
||||
config/database.js – SQLite Verbindung via better-sqlite3
|
||||
middleware/
|
||||
auth.js – authenticateToken (JWT prüfen)
|
||||
roleCheck.js – requireAdmin, requireStaff Middleware
|
||||
errorHandler.js – asyncHandler(fn) Wrapper für alle Controller
|
||||
controllers/ – Business Logic, eine Datei pro Feature
|
||||
models/ – SQLite Queries, eine Datei pro Entity
|
||||
routes/ – Express Router, eine Datei pro Feature
|
||||
services/ – Externe APIs (Graph API, Proxmox, Anthropic)
|
||||
\`\`\`
|
||||
|
||||
## Dateistruktur Frontend
|
||||
\`\`\`
|
||||
frontend/src/
|
||||
pages/ – Alle Seiten (NamingConvention: XxxPage.jsx)
|
||||
components/common/
|
||||
AppLayout.jsx – Wrapper mit Sidebar + Topbar
|
||||
Sidebar.jsx – Navigation
|
||||
Topbar.jsx – Header
|
||||
context/AuthContext.jsx – Auth State, user Objekt
|
||||
services/api.js – Axios Instance mit Interceptors (IMMER verwenden!)
|
||||
\`\`\`
|
||||
|
||||
## Kritische Konventionen Backend
|
||||
1. DB-Migration: Jedes ALTER TABLE / CREATE TABLE einzeln in try/catch in seed.js
|
||||
2. Controller immer mit asyncHandler wrappen: const foo = asyncHandler(async (req, res) => {...})
|
||||
3. Statische Routen VOR /:id definieren (z.B. /statistics vor /:id)
|
||||
4. better-sqlite3 ist SYNCHRON: db.prepare('...').get() – KEIN await!
|
||||
5. Exports am Ende: module.exports = { foo, bar }
|
||||
|
||||
## Kritische Konventionen Frontend
|
||||
1. IMMER import api from '../../services/api' – NIEMALS axios direkt importieren
|
||||
2. IMMER user.role_name – NIEMALS user.role (falsches Feld!)
|
||||
3. CSS Variablen verwenden:
|
||||
- Text: var(--text-primary), var(--text-secondary), var(--text-muted)
|
||||
- Hintergrund: var(--bg-card), var(--bg-secondary), var(--border-color)
|
||||
- Glassmorphism: backdrop-filter: blur(20px), rgba() Hintergründe
|
||||
4. Rollen: super_admin, admin, support, bearbeiter, benutzer, hr_personal, buchhaltung
|
||||
5. Neue Seite in AppLayout einbinden (Route in App.jsx)
|
||||
|
||||
## Deploy-Workflow
|
||||
\`\`\`
|
||||
scp datei.js root@192.168.0.194:/opt/it-nexus/backend/src/...
|
||||
docker cp /opt/it-nexus/backend/src/.../datei.js fido-backend:/app/src/.../
|
||||
docker restart fido-backend
|
||||
# Frontend rebuild nur nötig wenn neue Seite/Komponente:
|
||||
docker compose build frontend && docker compose up -d frontend
|
||||
\`\`\`
|
||||
|
||||
## Beispiel: Neue Feature-Struktur
|
||||
Backend: controller + model + route + seed.js Migration + server.js Route registrieren
|
||||
Frontend: XxxPage.jsx + xxxService.js + Route in App.jsx + Link in Sidebar.jsx
|
||||
`;
|
||||
|
||||
// ─── Agenten Definitionen ──────────────────────────────────────────────────────
|
||||
const AGENTS = {
|
||||
pm: {
|
||||
name: 'Project Manager',
|
||||
emoji: '📋',
|
||||
color: '#6366f1',
|
||||
system: `Du bist der Project Manager des IT Nexus Entwicklerteams. Du koordinierst alle anderen Agenten.
|
||||
|
||||
${IT_NEXUS_CONTEXT}
|
||||
|
||||
Deine Aufgabe:
|
||||
1. Analysiere die Anfrage des Benutzers
|
||||
2. Erstelle einen klaren Implementierungsplan
|
||||
3. Bestimme welche Spezialisten benötigt werden
|
||||
4. Fasse am Ende alles zusammen
|
||||
|
||||
Antworte auf Deutsch. Sei konkret und strukturiert. Nutze Markdown.
|
||||
Erkläre kurz was getan werden muss, dann liste die benötigten Schritte auf.`
|
||||
},
|
||||
|
||||
architect: {
|
||||
name: 'Architect',
|
||||
emoji: '🏗️',
|
||||
color: '#f59e0b',
|
||||
system: `Du bist der System-Architekt des IT Nexus Entwicklerteams.
|
||||
|
||||
${IT_NEXUS_CONTEXT}
|
||||
|
||||
Deine Aufgabe:
|
||||
- Entscheide über DB-Schema (SQLite Spalten, Typen, Constraints)
|
||||
- Entscheide über API-Struktur (Endpoints, HTTP-Methoden, Auth-Level)
|
||||
- Entscheide über Datenfluss zwischen Frontend und Backend
|
||||
- Identifiziere Abhängigkeiten zu bestehenden Features
|
||||
|
||||
Antworte auf Deutsch. Gib konkrete technische Entscheidungen.
|
||||
Format: DB-Schema → API-Endpoints → Abhängigkeiten → Besonderheiten`
|
||||
},
|
||||
|
||||
backend: {
|
||||
name: 'Backend Dev',
|
||||
emoji: '⚙️',
|
||||
color: '#10b981',
|
||||
system: `Du bist der Backend-Entwickler des IT Nexus Entwicklerteams. Du schreibst den kompletten Backend-Code.
|
||||
|
||||
${IT_NEXUS_CONTEXT}
|
||||
|
||||
Deine Aufgabe:
|
||||
- Schreibe vollständige, lauffähige Node.js Dateien
|
||||
- Folge exakt den Konventionen (asyncHandler, better-sqlite3 synchron, etc.)
|
||||
- Jede Datei vollständig mit korrekten Imports und Exports
|
||||
- DB-Migrations in seed.js Format
|
||||
|
||||
Format pro Datei:
|
||||
### Datei: \`backend/src/pfad/dateiname.js\`
|
||||
\`\`\`javascript
|
||||
// kompletter Code
|
||||
\`\`\`
|
||||
|
||||
Antworte auf Deutsch für Erklärungen, Code auf Englisch.`
|
||||
},
|
||||
|
||||
frontend: {
|
||||
name: 'Frontend Dev',
|
||||
emoji: '🎨',
|
||||
color: '#3b82f6',
|
||||
system: `Du bist der Frontend-Entwickler des IT Nexus Entwicklerteams. Du schreibst den kompletten Frontend-Code.
|
||||
|
||||
${IT_NEXUS_CONTEXT}
|
||||
|
||||
Deine Aufgabe:
|
||||
- Schreibe vollständige React Komponenten (.jsx Dateien)
|
||||
- Glassmorphism Design (konsistent mit bestehendem IT Nexus Style)
|
||||
- IMMER api.get/post aus services/api.js verwenden
|
||||
- IMMER user.role_name verwenden
|
||||
- CSS inline mit var(--*) Variablen
|
||||
|
||||
Format pro Datei:
|
||||
### Datei: \`frontend/src/pfad/DateiName.jsx\`
|
||||
\`\`\`jsx
|
||||
// kompletter Code
|
||||
\`\`\`
|
||||
|
||||
Antworte auf Deutsch für Erklärungen, Code auf Englisch.`
|
||||
},
|
||||
|
||||
senior: {
|
||||
name: 'Senior Dev',
|
||||
emoji: '🔍',
|
||||
color: '#ef4444',
|
||||
system: `Du bist der Senior Developer und Code Reviewer des IT Nexus Entwicklerteams.
|
||||
|
||||
${IT_NEXUS_CONTEXT}
|
||||
|
||||
Deine Aufgabe:
|
||||
- Reviewe den generierten Code auf Bugs, Sicherheitsprobleme, Konventionsverletzungen
|
||||
- Prüfe ob alle Konventionen eingehalten wurden (user.role_name, api.js, asyncHandler, etc.)
|
||||
- Prüfe auf fehlende Error Handling
|
||||
- Prüfe auf fehlende Route-Registrierungen in server.js
|
||||
- Prüfe auf fehlende Sidebar-Links
|
||||
- Gib konkrete Fixes wenn nötig
|
||||
|
||||
Format:
|
||||
✅ Was gut ist
|
||||
⚠️ Was fehlt / falsch ist + Fix
|
||||
📝 Deploy-Befehl am Ende
|
||||
|
||||
Antworte auf Deutsch.`
|
||||
},
|
||||
|
||||
qa: {
|
||||
name: 'QA Engineer',
|
||||
emoji: '🧪',
|
||||
color: '#8b5cf6',
|
||||
system: `Du bist der QA Engineer des IT Nexus Entwicklerteams.
|
||||
|
||||
${IT_NEXUS_CONTEXT}
|
||||
|
||||
Deine Aufgabe:
|
||||
- Identifiziere potenzielle Edge Cases
|
||||
- Schreibe manuelle Testschritte die der Entwickler ausführen soll
|
||||
- Prüfe ob alle API-Endpoints abgesichert sind (Auth)
|
||||
- Prüfe Fehlerszenarien (leere Daten, falscher Input, fehlende Rechte)
|
||||
- Schreibe wenn möglich einen Playwright-Test
|
||||
|
||||
Format:
|
||||
📋 Manuelle Tests (nummeriert)
|
||||
🔒 Security-Checks
|
||||
⚡ Edge Cases
|
||||
(Optional) Playwright Test Code
|
||||
|
||||
Antworte auf Deutsch.`
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Anthropic API Aufruf ──────────────────────────────────────────────────────
|
||||
function callClaude(systemPrompt, messages, onChunk) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const body = JSON.stringify({
|
||||
model: 'claude-sonnet-4-6',
|
||||
max_tokens: 8096,
|
||||
system: systemPrompt,
|
||||
messages,
|
||||
stream: true
|
||||
});
|
||||
|
||||
const req = https.request({
|
||||
hostname: 'api.anthropic.com',
|
||||
path: '/v1/messages',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': API_KEY,
|
||||
'anthropic-version': '2023-06-01',
|
||||
'Content-Length': Buffer.byteLength(body)
|
||||
}
|
||||
}, (res) => {
|
||||
let full = '';
|
||||
res.on('data', chunk => {
|
||||
const lines = chunk.toString().split('\n');
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const d = JSON.parse(line.slice(6));
|
||||
if (d.type === 'content_block_delta' && d.delta?.text) {
|
||||
full += d.delta.text;
|
||||
onChunk(d.delta.text);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
});
|
||||
res.on('end', () => resolve(full));
|
||||
res.on('error', reject);
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Orchestrierung ────────────────────────────────────────────────────────────
|
||||
async function runTeam(task, send) {
|
||||
const context = [];
|
||||
|
||||
const runAgent = async (agentId, userMsg) => {
|
||||
const agent = AGENTS[agentId];
|
||||
send({ type: 'agent_start', agent: agentId, name: agent.name, emoji: agent.emoji, color: agent.color });
|
||||
|
||||
const msgs = [...context, { role: 'user', content: userMsg }];
|
||||
let full = '';
|
||||
await callClaude(agent.system, msgs, chunk => {
|
||||
full += chunk;
|
||||
send({ type: 'chunk', agent: agentId, text: chunk });
|
||||
});
|
||||
|
||||
send({ type: 'agent_done', agent: agentId });
|
||||
context.push({ role: 'user', content: userMsg });
|
||||
context.push({ role: 'assistant', content: full });
|
||||
return full;
|
||||
};
|
||||
|
||||
// 1. PM analysiert
|
||||
const pmResult = await runAgent('pm',
|
||||
`Neue Aufgabe für das IT Nexus Entwicklerteam:\n\n"${task}"\n\nErstelle einen kurzen Implementierungsplan.`
|
||||
);
|
||||
|
||||
// 2. Architect entscheidet
|
||||
await runAgent('architect',
|
||||
`Aufgabe: "${task}"\n\nPM-Plan:\n${pmResult}\n\nGib deine technischen Entscheidungen für DB-Schema und API-Struktur.`
|
||||
);
|
||||
|
||||
// 3. Backend Dev schreibt Code
|
||||
const backendResult = await runAgent('backend',
|
||||
`Aufgabe: "${task}"\n\nSchreibe den vollständigen Backend-Code (Controller, Model, Route, seed.js Migration). Alle Dateien komplett.`
|
||||
);
|
||||
|
||||
// 4. Frontend Dev schreibt Code
|
||||
const frontendResult = await runAgent('frontend',
|
||||
`Aufgabe: "${task}"\n\nSchreibe den vollständigen Frontend-Code (Page, Service). Alle Dateien komplett.`
|
||||
);
|
||||
|
||||
// 5. Senior Dev reviewt
|
||||
await runAgent('senior',
|
||||
`Reviewe diesen Code:\n\n**Backend:**\n${backendResult}\n\n**Frontend:**\n${frontendResult}\n\nFinde Bugs, Konventionsverletzungen, fehlende Teile.`
|
||||
);
|
||||
|
||||
// 6. QA testet
|
||||
await runAgent('qa',
|
||||
`Aufgabe: "${task}"\n\nErstelle Testschritte und prüfe Edge Cases für die implementierte Funktion.`
|
||||
);
|
||||
|
||||
send({ type: 'done' });
|
||||
}
|
||||
|
||||
// ─── HTTP Server ───────────────────────────────────────────────────────────────
|
||||
const HTML = `<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>IT Nexus Dev Team</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||
background: #0a0f1e;
|
||||
color: #e2e8f0;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
background: rgba(15,23,42,0.95);
|
||||
border-bottom: 1px solid rgba(99,102,241,0.3);
|
||||
padding: 14px 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.header-logo {
|
||||
width: 36px; height: 36px;
|
||||
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||
border-radius: 10px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 18px;
|
||||
}
|
||||
.header h1 { font-size: 18px; font-weight: 700; color: #fff; }
|
||||
.header p { font-size: 12px; color: #64748b; margin-top: 1px; }
|
||||
.status-dot {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
background: #10b981; margin-left: auto;
|
||||
box-shadow: 0 0 8px #10b981;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.4} }
|
||||
|
||||
/* Layout */
|
||||
.main { display: flex; flex: 1; overflow: hidden; }
|
||||
|
||||
/* Sidebar: Team */
|
||||
.sidebar {
|
||||
width: 200px;
|
||||
background: rgba(15,23,42,0.8);
|
||||
border-right: 1px solid rgba(255,255,255,0.06);
|
||||
padding: 16px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sidebar-title {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: #475569;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
margin-bottom: 4px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.agent-card {
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.agent-card.active {
|
||||
background: rgba(255,255,255,0.06);
|
||||
color: #fff;
|
||||
border-color: rgba(255,255,255,0.1);
|
||||
}
|
||||
.agent-card.thinking {
|
||||
animation: agent-pulse 0.8s infinite;
|
||||
}
|
||||
@keyframes agent-pulse {
|
||||
0%,100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
.agent-emoji { font-size: 16px; }
|
||||
.agent-status {
|
||||
width: 6px; height: 6px; border-radius: 50%;
|
||||
background: #1e293b;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.agent-status.active { background: #10b981; box-shadow: 0 0 6px #10b981; }
|
||||
.agent-status.done { background: #6366f1; }
|
||||
|
||||
/* Chat Area */
|
||||
.chat { flex: 1; overflow-y: auto; padding: 20px; display: flex; flex-direction: column; gap: 16px; }
|
||||
.chat::-webkit-scrollbar { width: 4px; }
|
||||
.chat::-webkit-scrollbar-track { background: transparent; }
|
||||
.chat::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 2px; }
|
||||
|
||||
.msg { display: flex; gap: 12px; }
|
||||
.msg-avatar {
|
||||
width: 36px; height: 36px;
|
||||
border-radius: 10px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
}
|
||||
.msg-body { flex: 1; min-width: 0; }
|
||||
.msg-header {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.msg-name { font-size: 13px; font-weight: 700; }
|
||||
.msg-badge {
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.msg-content {
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: #cbd5e1;
|
||||
background: rgba(255,255,255,0.04);
|
||||
border: 1px solid rgba(255,255,255,0.06);
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Code Blocks */
|
||||
.msg-content pre {
|
||||
background: rgba(0,0,0,0.5);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
margin: 10px 0;
|
||||
overflow-x: auto;
|
||||
font-family: 'Cascadia Code', 'Fira Code', Consolas, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.msg-content code {
|
||||
background: rgba(0,0,0,0.4);
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Cascadia Code', Consolas, monospace;
|
||||
font-size: 12px;
|
||||
color: #7dd3fc;
|
||||
}
|
||||
.msg-content pre code {
|
||||
background: none; padding: 0; color: #e2e8f0;
|
||||
}
|
||||
.msg-content h1, .msg-content h2, .msg-content h3 {
|
||||
color: #f1f5f9; margin: 14px 0 6px;
|
||||
}
|
||||
.msg-content ul, .msg-content ol { padding-left: 20px; margin: 6px 0; }
|
||||
.msg-content li { margin: 3px 0; }
|
||||
.msg-content strong { color: #f1f5f9; }
|
||||
|
||||
/* User message */
|
||||
.msg.user .msg-content {
|
||||
background: rgba(99,102,241,0.1);
|
||||
border-color: rgba(99,102,241,0.3);
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
/* Input Area */
|
||||
.input-area {
|
||||
border-top: 1px solid rgba(255,255,255,0.06);
|
||||
background: rgba(15,23,42,0.95);
|
||||
padding: 16px 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.api-key-bar {
|
||||
display: flex; gap: 8px; margin-bottom: 10px; align-items: center;
|
||||
}
|
||||
.api-key-bar label { font-size: 11px; color: #475569; white-space: nowrap; }
|
||||
.api-key-bar input {
|
||||
flex: 1;
|
||||
background: rgba(255,255,255,0.05);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
font-family: monospace;
|
||||
}
|
||||
.input-row { display: flex; gap: 10px; }
|
||||
textarea {
|
||||
flex: 1;
|
||||
background: rgba(255,255,255,0.05);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
color: #e2e8f0;
|
||||
font-size: 13px;
|
||||
resize: none;
|
||||
font-family: inherit;
|
||||
line-height: 1.5;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
textarea:focus { outline: none; border-color: rgba(99,102,241,0.5); }
|
||||
textarea::placeholder { color: #334155; }
|
||||
.send-btn {
|
||||
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
padding: 0 20px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.send-btn:hover { opacity: 0.85; }
|
||||
.send-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.hint { font-size: 11px; color: #334155; margin-top: 8px; }
|
||||
|
||||
/* Welcome */
|
||||
.welcome {
|
||||
margin: auto;
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
max-width: 500px;
|
||||
}
|
||||
.welcome .big-emoji { font-size: 60px; margin-bottom: 16px; }
|
||||
.welcome h2 { font-size: 22px; font-weight: 700; color: #f1f5f9; margin-bottom: 8px; }
|
||||
.welcome p { font-size: 14px; color: #475569; line-height: 1.6; }
|
||||
.examples { margin-top: 24px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.example-btn {
|
||||
background: rgba(255,255,255,0.04);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.example-btn:hover { background: rgba(255,255,255,0.08); color: #e2e8f0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<div class="header-logo">👨💻</div>
|
||||
<div>
|
||||
<h1>IT Nexus Dev Team</h1>
|
||||
<p>6 KI-Agenten · Lokal · Nur für dich</p>
|
||||
</div>
|
||||
<div class="status-dot"></div>
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
<!-- Sidebar -->
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-title">Dev Team</div>
|
||||
<div class="agent-card" id="card-pm">
|
||||
<span class="agent-emoji">📋</span>
|
||||
<span>Project Manager</span>
|
||||
<div class="agent-status" id="status-pm"></div>
|
||||
</div>
|
||||
<div class="agent-card" id="card-architect">
|
||||
<span class="agent-emoji">🏗️</span>
|
||||
<span>Architect</span>
|
||||
<div class="agent-status" id="status-architect"></div>
|
||||
</div>
|
||||
<div class="agent-card" id="card-backend">
|
||||
<span class="agent-emoji">⚙️</span>
|
||||
<span>Backend Dev</span>
|
||||
<div class="agent-status" id="status-backend"></div>
|
||||
</div>
|
||||
<div class="agent-card" id="card-frontend">
|
||||
<span class="agent-emoji">🎨</span>
|
||||
<span>Frontend Dev</span>
|
||||
<div class="agent-status" id="status-frontend"></div>
|
||||
</div>
|
||||
<div class="agent-card" id="card-senior">
|
||||
<span class="agent-emoji">🔍</span>
|
||||
<span>Senior Dev</span>
|
||||
<div class="agent-status" id="status-senior"></div>
|
||||
</div>
|
||||
<div class="agent-card" id="card-qa">
|
||||
<span class="agent-emoji">🧪</span>
|
||||
<span>QA Engineer</span>
|
||||
<div class="agent-status" id="status-qa"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chat -->
|
||||
<div class="chat" id="chat">
|
||||
<div class="welcome" id="welcome">
|
||||
<div class="big-emoji">🚀</div>
|
||||
<h2>Dein persönliches Dev Team</h2>
|
||||
<p>Beschreibe was du bauen möchtest. Das Team analysiert, plant, schreibt Code und reviewt alles automatisch.</p>
|
||||
<div class="examples">
|
||||
<button class="example-btn" onclick="setExample(this)">📦 Neue Seite für Lizenzmanagement (CRUD, DB, API, React)</button>
|
||||
<button class="example-btn" onclick="setExample(this)">🔔 E-Mail-Benachrichtigung wenn Agent offline ist</button>
|
||||
<button class="example-btn" onclick="setExample(this)">📊 Dashboard-Widget mit Top 5 offenen Tickets</button>
|
||||
<button class="example-btn" onclick="setExample(this)">🔐 Zwei-Faktor-Authentifizierung für Admin-Login</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Input -->
|
||||
<div class="input-area">
|
||||
<div class="api-key-bar">
|
||||
<label>🔑 API Key:</label>
|
||||
<input type="password" id="apiKey" placeholder="sk-ant-..." oninput="saveKey(this.value)" />
|
||||
</div>
|
||||
<div class="input-row">
|
||||
<textarea id="taskInput" rows="2" placeholder="Was soll das Team bauen? (Enter = Senden, Shift+Enter = Neue Zeile)"></textarea>
|
||||
<button class="send-btn" id="sendBtn" onclick="sendTask()">Team starten ▶</button>
|
||||
</div>
|
||||
<div class="hint">⚡ PM → Architect → Backend Dev → Frontend Dev → Senior Dev → QA Engineer</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const COLORS = {
|
||||
pm: '#6366f1', architect: '#f59e0b', backend: '#10b981',
|
||||
frontend: '#3b82f6', senior: '#ef4444', qa: '#8b5cf6'
|
||||
};
|
||||
const NAMES = {
|
||||
pm: 'Project Manager', architect: 'Architect', backend: 'Backend Dev',
|
||||
frontend: 'Frontend Dev', senior: 'Senior Dev', qa: 'QA Engineer'
|
||||
};
|
||||
const EMOJIS = {
|
||||
pm: '📋', architect: '🏗️', backend: '⚙️',
|
||||
frontend: '🎨', senior: '🔍', qa: '🧪'
|
||||
};
|
||||
|
||||
// API Key aus localStorage laden
|
||||
window.onload = () => {
|
||||
const saved = localStorage.getItem('devteam_apikey');
|
||||
if (saved) document.getElementById('apiKey').value = saved;
|
||||
};
|
||||
function saveKey(v) { localStorage.setItem('devteam_apikey', v); }
|
||||
|
||||
function setExample(btn) {
|
||||
document.getElementById('taskInput').value = btn.textContent.replace(/^[^\s]+\s/, '').trim();
|
||||
document.getElementById('taskInput').focus();
|
||||
}
|
||||
|
||||
document.getElementById('taskInput').addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendTask(); }
|
||||
});
|
||||
|
||||
let currentMsgEl = null;
|
||||
let currentRaw = '';
|
||||
|
||||
function addUserMsg(text) {
|
||||
document.getElementById('welcome')?.remove();
|
||||
const el = document.createElement('div');
|
||||
el.className = 'msg user';
|
||||
el.innerHTML = \`
|
||||
<div class="msg-avatar" style="background:rgba(99,102,241,0.2)">👤</div>
|
||||
<div class="msg-body">
|
||||
<div class="msg-header">
|
||||
<span class="msg-name" style="color:#6366f1">Du</span>
|
||||
</div>
|
||||
<div class="msg-content">\${escHtml(text)}</div>
|
||||
</div>\`;
|
||||
document.getElementById('chat').appendChild(el);
|
||||
scrollChat();
|
||||
}
|
||||
|
||||
function startAgentMsg(agentId) {
|
||||
// Sidebar aktualisieren
|
||||
document.querySelectorAll('.agent-card').forEach(c => c.classList.remove('active','thinking'));
|
||||
document.querySelectorAll('.agent-status').forEach(s => { s.classList.remove('active'); });
|
||||
const card = document.getElementById('card-' + agentId);
|
||||
const status = document.getElementById('status-' + agentId);
|
||||
if (card) { card.classList.add('active','thinking'); }
|
||||
if (status) { status.classList.add('active'); }
|
||||
|
||||
const color = COLORS[agentId];
|
||||
const name = NAMES[agentId];
|
||||
const emoji = EMOJIS[agentId];
|
||||
|
||||
const el = document.createElement('div');
|
||||
el.className = 'msg';
|
||||
el.id = 'msg-' + agentId;
|
||||
el.innerHTML = \`
|
||||
<div class="msg-avatar" style="background:\${color}20; border-color:\${color}40">\${emoji}</div>
|
||||
<div class="msg-body">
|
||||
<div class="msg-header">
|
||||
<span class="msg-name" style="color:\${color}">\${name}</span>
|
||||
<span class="msg-badge" style="background:\${color}20;color:\${color}">schreibt...</span>
|
||||
</div>
|
||||
<div class="msg-content" id="content-\${agentId}"><span class="cursor">▌</span></div>
|
||||
</div>\`;
|
||||
document.getElementById('chat').appendChild(el);
|
||||
currentMsgEl = document.getElementById('content-' + agentId);
|
||||
currentRaw = '';
|
||||
scrollChat();
|
||||
}
|
||||
|
||||
function appendChunk(agentId, text) {
|
||||
currentRaw += text;
|
||||
if (currentMsgEl) {
|
||||
currentMsgEl.innerHTML = renderMarkdown(currentRaw) + '<span class="cursor">▌</span>';
|
||||
scrollChat();
|
||||
}
|
||||
}
|
||||
|
||||
function doneAgentMsg(agentId) {
|
||||
if (currentMsgEl) {
|
||||
currentMsgEl.innerHTML = renderMarkdown(currentRaw);
|
||||
}
|
||||
const card = document.getElementById('card-' + agentId);
|
||||
const status = document.getElementById('status-' + agentId);
|
||||
if (card) card.classList.remove('thinking');
|
||||
if (status) { status.classList.remove('active'); status.classList.add('done'); }
|
||||
|
||||
// Badge aktualisieren
|
||||
const msg = document.getElementById('msg-' + agentId);
|
||||
if (msg) {
|
||||
const badge = msg.querySelector('.msg-badge');
|
||||
if (badge) { badge.textContent = 'fertig ✓'; badge.style.background = COLORS[agentId]+'30'; }
|
||||
}
|
||||
currentMsgEl = null;
|
||||
}
|
||||
|
||||
function renderMarkdown(text) {
|
||||
return text
|
||||
.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
|
||||
.replace(/\`\`\`(\w+)?\n([\s\S]*?)\`\`\`/g, '<pre><code>$2</code></pre>')
|
||||
.replace(/\`([^\`]+)\`/g, '<code>$1</code>')
|
||||
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
|
||||
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
|
||||
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/^- (.+)$/gm, '<li>$1</li>')
|
||||
.replace(/^(\d+)\. (.+)$/gm, '<li>$1. $2</li>')
|
||||
.replace(/\n\n/g, '<br><br>')
|
||||
.replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
function escHtml(t) {
|
||||
return t.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
function scrollChat() {
|
||||
const chat = document.getElementById('chat');
|
||||
chat.scrollTop = chat.scrollHeight;
|
||||
}
|
||||
|
||||
async function sendTask() {
|
||||
const task = document.getElementById('taskInput').value.trim();
|
||||
const apiKey = document.getElementById('apiKey').value.trim();
|
||||
|
||||
if (!task) return;
|
||||
if (!apiKey) { alert('Bitte zuerst den Anthropic API Key eingeben!'); return; }
|
||||
|
||||
const btn = document.getElementById('sendBtn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '⏳ Team arbeitet...';
|
||||
|
||||
addUserMsg(task);
|
||||
document.getElementById('taskInput').value = '';
|
||||
|
||||
// Alle Status zurücksetzen
|
||||
document.querySelectorAll('.agent-status').forEach(s => s.classList.remove('active','done'));
|
||||
document.querySelectorAll('.agent-card').forEach(c => c.classList.remove('active','thinking'));
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/task', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ task, apiKey })
|
||||
});
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
const lines = buf.split('\n\n');
|
||||
buf = lines.pop();
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const d = JSON.parse(line.slice(6));
|
||||
if (d.type === 'agent_start') startAgentMsg(d.agent);
|
||||
else if (d.type === 'chunk') appendChunk(d.agent, d.text);
|
||||
else if (d.type === 'agent_done') doneAgentMsg(d.agent);
|
||||
else if (d.type === 'done') {
|
||||
document.querySelectorAll('.agent-card').forEach(c => c.classList.remove('active','thinking'));
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
const el = document.createElement('div');
|
||||
el.style.cssText = 'background:rgba(239,68,68,0.1);border:1px solid rgba(239,68,68,0.3);border-radius:8px;padding:12px;color:#fca5a5;font-size:13px;';
|
||||
el.textContent = '❌ Fehler: ' + e.message;
|
||||
document.getElementById('chat').appendChild(el);
|
||||
}
|
||||
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Team starten ▶';
|
||||
scrollChat();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const url = new URL(req.url, `http://localhost:${PORT}`);
|
||||
|
||||
// HTML
|
||||
if (req.method === 'GET' && url.pathname === '/') {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(HTML);
|
||||
return;
|
||||
}
|
||||
|
||||
// Task API
|
||||
if (req.method === 'POST' && url.pathname === '/api/task') {
|
||||
let body = '';
|
||||
req.on('data', c => body += c);
|
||||
req.on('end', async () => {
|
||||
try {
|
||||
const { task, apiKey } = JSON.parse(body);
|
||||
|
||||
// API Key aus Request oder Umgebungsvariable
|
||||
const key = apiKey || API_KEY;
|
||||
if (!key) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Kein API Key' }));
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'Access-Control-Allow-Origin': '*'
|
||||
});
|
||||
|
||||
const send = (data) => res.write(`data: ${JSON.stringify(data)}\n\n`);
|
||||
|
||||
// Agenten mit aktuellem Key aufrufen
|
||||
const callAgent = (systemPrompt, messages) => new Promise((resolve, reject) => {
|
||||
const reqBody = JSON.stringify({
|
||||
model: 'claude-sonnet-4-6',
|
||||
max_tokens: 8096,
|
||||
system: systemPrompt,
|
||||
messages,
|
||||
stream: true
|
||||
});
|
||||
|
||||
const apiReq = https.request({
|
||||
hostname: 'api.anthropic.com',
|
||||
path: '/v1/messages',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': key,
|
||||
'anthropic-version': '2023-06-01',
|
||||
'Content-Length': Buffer.byteLength(reqBody)
|
||||
}
|
||||
}, (apiRes) => {
|
||||
let full = '';
|
||||
let buf = '';
|
||||
apiRes.on('data', chunk => {
|
||||
buf += chunk.toString();
|
||||
const lines = buf.split('\n');
|
||||
buf = lines.pop();
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const d = JSON.parse(line.slice(6));
|
||||
if (d.type === 'content_block_delta' && d.delta?.text) {
|
||||
full += d.delta.text;
|
||||
send({ type: 'chunk', agent: currentAgent, text: d.delta.text });
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
});
|
||||
apiRes.on('end', () => resolve(full));
|
||||
apiRes.on('error', reject);
|
||||
});
|
||||
apiReq.on('error', reject);
|
||||
apiReq.write(reqBody);
|
||||
apiReq.end();
|
||||
});
|
||||
|
||||
let currentAgent = '';
|
||||
const context = [];
|
||||
|
||||
const runAgent = async (agentId, userMsg) => {
|
||||
currentAgent = agentId;
|
||||
send({ type: 'agent_start', agent: agentId });
|
||||
const msgs = [...context, { role: 'user', content: userMsg }];
|
||||
const full = await callAgent(AGENTS[agentId].system, msgs);
|
||||
send({ type: 'agent_done', agent: agentId });
|
||||
context.push({ role: 'user', content: userMsg });
|
||||
context.push({ role: 'assistant', content: full });
|
||||
return full;
|
||||
};
|
||||
|
||||
// Team ausführen
|
||||
const pmResult = await runAgent('pm',
|
||||
`Neue Aufgabe:\n\n"${task}"\n\nErstelle einen kurzen klaren Implementierungsplan für das IT Nexus Entwicklerteam.`
|
||||
);
|
||||
|
||||
await runAgent('architect',
|
||||
`Aufgabe: "${task}"\n\nPM-Plan:\n${pmResult}\n\nEntscheide über DB-Schema, API-Endpoints und technische Architektur.`
|
||||
);
|
||||
|
||||
const backendResult = await runAgent('backend',
|
||||
`Aufgabe: "${task}"\n\nSchreibe jetzt den vollständigen Backend-Code. Alle Dateien komplett und lauffähig. Folge exakt den IT Nexus Konventionen.`
|
||||
);
|
||||
|
||||
const frontendResult = await runAgent('frontend',
|
||||
`Aufgabe: "${task}"\n\nSchreibe jetzt den vollständigen Frontend-Code. Alle Dateien komplett. Folge exakt den IT Nexus Konventionen.`
|
||||
);
|
||||
|
||||
await runAgent('senior',
|
||||
`Reviewe diesen Code:\n\nBACKEND:\n${backendResult}\n\nFRONTEND:\n${frontendResult}\n\nFinde alle Bugs, Konventionsverletzungen und fehlende Teile. Gib Fixes.`
|
||||
);
|
||||
|
||||
await runAgent('qa',
|
||||
`Aufgabe: "${task}"\n\nErstelle konkrete Testschritte, Security-Checks und Edge Cases.`
|
||||
);
|
||||
|
||||
send({ type: 'done' });
|
||||
res.end();
|
||||
} catch (e) {
|
||||
try {
|
||||
res.write(`data: ${JSON.stringify({ type: 'error', text: e.message })}\n\n`);
|
||||
res.end();
|
||||
} catch {}
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
});
|
||||
|
||||
server.listen(PORT, '127.0.0.1', () => {
|
||||
console.log('');
|
||||
console.log('╔═══════════════════════════════════════╗');
|
||||
console.log('║ IT Nexus Dev Team — Bereit! ║');
|
||||
console.log('╠═══════════════════════════════════════╣');
|
||||
console.log(`║ → http://localhost:${PORT} ║`);
|
||||
console.log('║ ║');
|
||||
console.log('║ 6 Agenten: PM, Architect, ║');
|
||||
console.log('║ Backend, Frontend, Senior, QA ║');
|
||||
console.log('╚═══════════════════════════════════════╝');
|
||||
console.log('');
|
||||
if (!API_KEY) {
|
||||
console.log('⚠️ Kein ANTHROPIC_API_KEY gesetzt — im Browser eingeben');
|
||||
console.log(' Oder: set ANTHROPIC_API_KEY=sk-ant-... && node devteam.js');
|
||||
}
|
||||
console.log('');
|
||||
});
|
||||
@@ -13,9 +13,15 @@ services:
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=5000
|
||||
- JWT_SECRET=jwt-jItWbLMHTIDElss0C3tMN6NV3hq3GjEIPXp79-X8ljiMS6OMX541Xg
|
||||
- AGENT_API_KEY=itx-4CPJPTHmCfdrL9D62WacCATEvuvULXcp7ECMpSaNUjsS344F6_L4Ug
|
||||
- SCANNER_API_KEY=nsx-aa0193d719c2be765441e1dd24bf7480
|
||||
- JWT_SECRET=${JWT_SECRET}
|
||||
- AGENT_API_KEY=${AGENT_API_KEY}
|
||||
- SCANNER_API_KEY=${SCANNER_API_KEY}
|
||||
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
|
||||
- TV_DASHBOARD_KEY=${TV_DASHBOARD_KEY}
|
||||
# Globaler Fallback für Agents ohne Patch-Gruppe ODER ohne ?hostname-Parameter (alte Agent-Versionen
|
||||
# vor v2.5.0) — bei jedem neuen Agent-Release IMMER mit hochziehen, sonst fallen alte/gruppenlose
|
||||
# Agents auf den hartcodierten Code-Default zurück (war zuvor 2.0.0 — ein echter Downgrade-Bug)!
|
||||
- AGENT_VERSION=${AGENT_VERSION}
|
||||
- JWT_EXPIRATION=8h
|
||||
- DATABASE_PATH=/app/data/database.sqlite
|
||||
- FRONTEND_URL=https://it-nexus.cereda-systems.de
|
||||
@@ -25,13 +31,13 @@ services:
|
||||
- SMTP_PORT=587
|
||||
- SMTP_SECURE=false
|
||||
- SMTP_USER=it-tool@cereda-systems.de
|
||||
- SMTP_PASSWORD=Cereda2025!
|
||||
- SMTP_PASSWORD=${SMTP_PASSWORD}
|
||||
- SMTP_FROM=IT Support Cereda <it-tool@cereda-systems.de>
|
||||
# IMAP (eingehende E-Mails → Tickets)
|
||||
- IMAP_HOST=outlook.office365.com
|
||||
- IMAP_PORT=993
|
||||
- IMAP_USER=it-tool@cereda-systems.de
|
||||
- IMAP_PASSWORD=Cereda2025!
|
||||
- IMAP_PASSWORD=${IMAP_PASSWORD}
|
||||
- IMAP_MAILBOX=INBOX
|
||||
# Ticket-Links in E-Mails
|
||||
- TICKET_BASE_URL=https://it-nexus.cereda-systems.de
|
||||
@@ -40,21 +46,21 @@ services:
|
||||
# Microsoft Azure AD (OAuth2 + Graph API)
|
||||
- AZURE_TENANT_ID=6a9a4382-ea6c-4e64-a09f-0905f83b3d64
|
||||
- AZURE_CLIENT_ID=de5c4a83-d24d-4323-9f3b-9c3a2f6c709f
|
||||
- AZURE_CLIENT_SECRET=55f8Q~BAYuFl5-zsX4Aob397PKLZssNTVc9rFb9n
|
||||
- AZURE_CLIENT_SECRET=${AZURE_CLIENT_SECRET}
|
||||
- AZURE_REDIRECT_URI=https://it-nexus.cereda-systems.de/api/auth/microsoft/callback
|
||||
# Anthropic Claude API (KI-Integration)
|
||||
- ANTHROPIC_API_KEY=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
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
|
||||
# Teams Bot
|
||||
- TEAMS_BOT_APP_ID=bd39de7e-1b85-4e0c-9301-a68cd5122523
|
||||
- TEAMS_BOT_APP_SECRET=jos8Q~UjN.Z65.3e9JeXpeKJdX6li58I2EnhfbZQ
|
||||
- TEAMS_BOT_APP_SECRET=${TEAMS_BOT_APP_SECRET}
|
||||
# Microsoft Planner Sync
|
||||
- PLANNER_PLAN_ID=CFyoAifRzUi2ONlk01smWpcAHtm3
|
||||
# Proxmox Monitoring
|
||||
- PROXMOX_HOST=https://192.168.0.184:8006
|
||||
- PROXMOX_TOKEN=root@pam!monitoring=ca287965-3d33-49ec-a069-1bb80fd2e95d
|
||||
- PROXMOX_TOKEN=${PROXMOX_TOKEN}
|
||||
- PROXMOX_NODE=hve-01
|
||||
# GitHub API (Feedback-System)
|
||||
- GITHUB_TOKEN=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
|
||||
- GITHUB_TOKEN=${GITHUB_TOKEN}
|
||||
volumes:
|
||||
# Persistente Datenbank - bleibt auch nach Container-Neustart erhalten
|
||||
- fido-data:/app/data
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net472</TargetFramework>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<AssemblyName>DomainJoinTool</AssemblyName>
|
||||
<RootNamespace>DomainJoinTool</RootNamespace>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<ApplicationIcon>icon.ico</ApplicationIcon>
|
||||
<LangVersion>9.0</LangVersion>
|
||||
<PlatformTarget>x64</PlatformTarget>
|
||||
<Optimize>true</Optimize>
|
||||
<DebugType>none</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="System.Management.Automation" Condition="Exists('$(PSHOME)\System.Management.Automation.dll')">
|
||||
<HintPath>$(PSHOME)\System.Management.Automation.dll</HintPath>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,642 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Drawing;
|
||||
using System.Drawing.Drawing2D;
|
||||
using System.Drawing.Text;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Forms;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace DomainJoinTool
|
||||
{
|
||||
public class MainForm : Form
|
||||
{
|
||||
// ── Cereda/Zelo Colors ────────────────────────────────────────────────
|
||||
static readonly Color CTeal = ColorTranslator.FromHtml("#0D9488");
|
||||
static readonly Color CTealD = ColorTranslator.FromHtml("#0F766E");
|
||||
static readonly Color CGreen = ColorTranslator.FromHtml("#059669");
|
||||
static readonly Color CRed = ColorTranslator.FromHtml("#DC2626");
|
||||
static readonly Color CAmber = ColorTranslator.FromHtml("#D97706");
|
||||
static readonly Color CGray = ColorTranslator.FromHtml("#6B7280");
|
||||
static readonly Color CBG = ColorTranslator.FromHtml("#F8FAFC");
|
||||
static readonly Color CBorder= ColorTranslator.FromHtml("#E2E8F0");
|
||||
static readonly Color CText = ColorTranslator.FromHtml("#0F172A");
|
||||
static readonly Color CText2 = ColorTranslator.FromHtml("#64748B");
|
||||
|
||||
// ── State ─────────────────────────────────────────────────────────────
|
||||
bool _entra, _domain; string _dn = ""; int _step;
|
||||
Timer _animTimer; float _phase, _spin;
|
||||
|
||||
// ── Controls ──────────────────────────────────────────────────────────
|
||||
StatusBadge _badge;
|
||||
Panel _body1, _body2;
|
||||
ModernInput _txUser, _txPass;
|
||||
RoundButton _btnJoin, _btnLeave, _btnMig;
|
||||
Label _lnkBack, _lnkTab1, _lnkTab2;
|
||||
ComboBox _cbSrc, _cbDst;
|
||||
ProgressBar _pb; Label _lbPb;
|
||||
RichTextBox _log;
|
||||
Label _lblStat;
|
||||
Label _lblMigStatus;
|
||||
ProgressBar _pbMig;
|
||||
Label _lblMigCount;
|
||||
RichTextBox _logMig;
|
||||
|
||||
const string DOM = "winkel.local";
|
||||
const string LOG = @"C:\ProgramData\DomainJoinTool\join.log";
|
||||
const string FLG = @"C:\ProgramData\DomainJoinTool\pending-migration.txt";
|
||||
const string EXE = @"C:\ProgramData\DomainJoinTool\DomainJoinTool.exe";
|
||||
|
||||
public MainForm()
|
||||
{
|
||||
Text = "Cereda Systems · Domain Join Tool";
|
||||
ClientSize = new Size(620, 680);
|
||||
FormBorderStyle = FormBorderStyle.FixedSingle;
|
||||
MaximizeBox = false; StartPosition = FormStartPosition.CenterScreen;
|
||||
BackColor = CBG; Font = new Font("Segoe UI", 9f); Icon = MakeIcon();
|
||||
Build();
|
||||
_animTimer = new Timer { Interval = 16 };
|
||||
_animTimer.Tick += (s, e) => { _phase = (_phase + 0.025f) % 1f; _spin = (_spin + 9f) % 360f; _badge?.Tick(_phase, _spin); };
|
||||
_animTimer.Start();
|
||||
DetectStatus();
|
||||
LoadProfiles();
|
||||
}
|
||||
|
||||
void Build()
|
||||
{
|
||||
// ── Header ────────────────────────────────────────────────────────
|
||||
var hdr = new Panel { Dock = DockStyle.Top, Height = 64, BackColor = CTeal };
|
||||
hdr.Paint += (s, e) =>
|
||||
{
|
||||
var g = e.Graphics;
|
||||
g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias;
|
||||
g.TextRenderingHint = TextRenderingHint.ClearTypeGridFit;
|
||||
// Subtle darker stripe at bottom
|
||||
using (var br = new SolidBrush(Color.FromArgb(30, 0, 0, 0)))
|
||||
g.FillRectangle(br, 0, hdr.Height - 3, hdr.Width, 3);
|
||||
// Logo circle
|
||||
using (var br = new SolidBrush(Color.FromArgb(40, 255, 255, 255)))
|
||||
g.FillEllipse(br, 14, 10, 36, 36);
|
||||
using (var f = new Font("Segoe UI", 11f, FontStyle.Bold))
|
||||
using (var br = new SolidBrush(Color.White))
|
||||
{ var sf = new System.Drawing.StringFormat { Alignment = System.Drawing.StringAlignment.Center, LineAlignment = System.Drawing.StringAlignment.Center }; g.DrawString("IT", f, br, new RectangleF(14, 10, 36, 36), sf); }
|
||||
// Text
|
||||
using (var br = new SolidBrush(Color.FromArgb(200, 255, 255, 255)))
|
||||
g.DrawString("CEREDA SYSTEMS", new Font("Segoe UI", 7.5f, FontStyle.Bold), br, 60, 12);
|
||||
g.DrawString("Domain Join Tool", new Font("Segoe UI", 13f, FontStyle.Bold), Brushes.White, 58, 28);
|
||||
// Version badge
|
||||
using (var br = new SolidBrush(Color.FromArgb(40, 255, 255, 255)))
|
||||
g.FillRectangle(br, hdr.Width - 68, 20, 56, 20);
|
||||
using (var br = new SolidBrush(Color.FromArgb(200, 255, 255, 255)))
|
||||
g.DrawString("v2.5.0", new Font("Segoe UI", 8f), br, new RectangleF(hdr.Width - 68, 20, 56, 20),
|
||||
new System.Drawing.StringFormat { Alignment = System.Drawing.StringAlignment.Center, LineAlignment = System.Drawing.StringAlignment.Center });
|
||||
};
|
||||
Controls.Add(hdr);
|
||||
|
||||
// ── Tab bar ───────────────────────────────────────────────────────
|
||||
var tabBar = new Panel { Dock = DockStyle.Top, Height = 38, BackColor = Color.White };
|
||||
tabBar.Paint += (s, e) => {
|
||||
e.Graphics.DrawLine(new Pen(CBorder), 0, 37, 600, 37);
|
||||
// Active indicator
|
||||
if (_step == 0) e.Graphics.FillRectangle(new SolidBrush(CTeal), 16, 35, 120, 3);
|
||||
else e.Graphics.FillRectangle(new SolidBrush(CTeal), 148, 35, 140, 3);
|
||||
};
|
||||
_lnkTab1 = TabLabel("1 · Domain Join", new Point(16, 8), true);
|
||||
_lnkTab2 = TabLabel("2 · Profil Migration", new Point(148, 8), false);
|
||||
_lnkTab1.Click += (s, e) => GoTo(0, tabBar);
|
||||
_lnkTab2.Click += (s, e) => GoTo(1, tabBar);
|
||||
tabBar.Controls.Add(_lnkTab1); tabBar.Controls.Add(_lnkTab2);
|
||||
Controls.Add(tabBar);
|
||||
|
||||
// ── Bodies (explicit position to avoid Dock=Fill overlap bug) ────
|
||||
// hdr=64 + tabBar=38 = 102px
|
||||
_body1 = new Panel { Location = new Point(0, 102), Size = new Size(620, 578), BackColor = CBG };
|
||||
_body2 = new Panel { Location = new Point(0, 102), Size = new Size(620, 578), BackColor = CBG, Visible = false };
|
||||
BuildBody1(); BuildBody2();
|
||||
Controls.Add(_body1); Controls.Add(_body2);
|
||||
}
|
||||
|
||||
Label TabLabel(string t, Point loc, bool active)
|
||||
{
|
||||
var l = new Label { Text = t, Font = new Font("Segoe UI", 9f, active ? FontStyle.Bold : FontStyle.Regular), ForeColor = active ? CTeal : CText2, Location = loc, AutoSize = true, Cursor = Cursors.Hand };
|
||||
l.MouseEnter += (s, e) => l.ForeColor = CTeal;
|
||||
l.MouseLeave += (s, e) => l.ForeColor = (_step == (l == _lnkTab1 ? 0 : 1)) ? CTeal : CText2;
|
||||
return l;
|
||||
}
|
||||
|
||||
void GoTo(int step, Panel tabBar = null)
|
||||
{
|
||||
_step = step; _body1.Visible = step == 0; _body2.Visible = step == 1;
|
||||
_lnkTab1.Font = new Font("Segoe UI", 9f, step == 0 ? FontStyle.Bold : FontStyle.Regular);
|
||||
_lnkTab2.Font = new Font("Segoe UI", 9f, step == 1 ? FontStyle.Bold : FontStyle.Regular);
|
||||
_lnkTab1.ForeColor = step == 0 ? CTeal : CText2;
|
||||
_lnkTab2.ForeColor = step == 1 ? CTeal : CText2;
|
||||
(tabBar ?? _body1.Parent?.Parent as Panel)?.Invalidate();
|
||||
// Find and invalidate the tabBar
|
||||
foreach (Control c in Controls) if (c is Panel p && p.Height == 38) { p.Invalidate(); break; }
|
||||
}
|
||||
|
||||
// ── Body 1: Domain Join ───────────────────────────────────────────────
|
||||
void BuildBody1()
|
||||
{
|
||||
int m = 16, y = 14, w = 588;
|
||||
|
||||
// ── Status Card ───────────────────────────────────────────────────
|
||||
var statCard = MkCard(m, y, w, 86); y += 98;
|
||||
|
||||
_lblStat = new Label { Text = "Status", Font = new Font("Segoe UI", 9f, FontStyle.Bold), ForeColor = CTealD, Location = new Point(20, 10), AutoSize = true };
|
||||
statCard.Controls.Add(_lblStat);
|
||||
statCard.Controls.Add(new Panel { Location = new Point(20, 30), Size = new Size(w - 26, 1), BackColor = CBorder });
|
||||
|
||||
_badge = new StatusBadge { Location = new Point(20, 40) };
|
||||
statCard.Controls.Add(_badge);
|
||||
|
||||
// Right: Entra → AD
|
||||
statCard.Controls.Add(new Label { Text = "Entra ID", Font = new Font("Segoe UI", 8.5f, FontStyle.Bold), ForeColor = CText2, Location = new Point(280, 38), AutoSize = true });
|
||||
statCard.Controls.Add(new Label { Text = "→", Font = new Font("Segoe UI", 14f), ForeColor = Color.FromArgb(180, 180, 200), Location = new Point(358, 32), AutoSize = true });
|
||||
statCard.Controls.Add(new Label { Text = DOM, Font = new Font("Segoe UI", 8.5f, FontStyle.Bold), ForeColor = CGreen, Location = new Point(382, 38), AutoSize = true });
|
||||
statCard.Controls.Add(new Label { Text = Environment.MachineName, Font = new Font("Cascadia Mono", 7.5f), ForeColor = CText2, Location = new Point(280, 57), AutoSize = true });
|
||||
_body1.Controls.Add(statCard);
|
||||
|
||||
// ── Credentials Card ──────────────────────────────────────────────
|
||||
var credCard = MkCard(m, y, w, 168); y += 180;
|
||||
|
||||
credCard.Controls.Add(new Label { Text = "Domain-Zugangsdaten", Font = new Font("Segoe UI", 10f, FontStyle.Bold), ForeColor = CTealD, Location = new Point(20, 12), AutoSize = true });
|
||||
credCard.Controls.Add(new Label { Text = "Ziel: " + DOM, Font = new Font("Cascadia Mono", 8f, FontStyle.Bold), ForeColor = CGreen, Location = new Point(220, 15), AutoSize = true });
|
||||
credCard.Controls.Add(new Panel { Location = new Point(20, 36), Size = new Size(w - 26, 1), BackColor = CBorder });
|
||||
_txUser = Inp(new Point(20, 44), new Size(w - 40, 52), "Benutzername"); _txUser.Val = "Administrator";
|
||||
_txPass = Inp(new Point(20, 104), new Size(w - 40, 52), "Passwort", true);
|
||||
credCard.Controls.Add(_txUser); credCard.Controls.Add(_txPass);
|
||||
_body1.Controls.Add(credCard);
|
||||
|
||||
// ── Action Buttons ────────────────────────────────────────────────
|
||||
_btnJoin = new RoundButton("▶ Entra leaven + Domain joinen", CTeal, 10) { Location = new Point(m, y), Size = new Size(420, 44) };
|
||||
_btnLeave = new RoundButton("■ Nur Entra verlassen", CRed, 10) { Location = new Point(m + 428, y), Size = new Size(160, 44) };
|
||||
_btnJoin.Click += DoJoin; _btnLeave.Click += DoLeave;
|
||||
_body1.Controls.Add(_btnJoin); _body1.Controls.Add(_btnLeave); y += 52;
|
||||
|
||||
// ── Progress ─────────────────────────────────────────────────────
|
||||
_pb = new ProgressBar { Location = new Point(m, y), Size = new Size(w, 4), Style = ProgressBarStyle.Marquee, MarqueeAnimationSpeed = 25, Visible = false }; y += 8;
|
||||
_lbPb = Lbl("Bitte warten…", new Point(m, y)); _lbPb.Visible = false; y += 22;
|
||||
_body1.Controls.Add(_pb); _body1.Controls.Add(_lbPb);
|
||||
|
||||
// ── Log ───────────────────────────────────────────────────────────
|
||||
_body1.Controls.Add(Lbl("Log · " + LOG, new Point(m, y), new Font("Cascadia Mono", 7.5f), CText2)); y += 16;
|
||||
_log = new RichTextBox { Location = new Point(m, y), Size = new Size(w, 100), BackColor = Color.FromArgb(13, 17, 23), ForeColor = Color.FromArgb(100, 210, 130), Font = new Font("Cascadia Mono", 8f), ReadOnly = true, BorderStyle = BorderStyle.None };
|
||||
_body1.Controls.Add(_log);
|
||||
}
|
||||
|
||||
// ── Body 2: Migration ─────────────────────────────────────────────────
|
||||
void BuildBody2()
|
||||
{
|
||||
int m = 16, y = 14, w = 588;
|
||||
|
||||
var infoCard = MkCard(m, y, w, 140); y += 152;
|
||||
infoCard.Controls.Add(new Label { Text = "Was wird migriert", Font = new Font("Segoe UI", 10f, FontStyle.Bold), ForeColor = CTealD, Location = new Point(20, 12), AutoSize = true });
|
||||
infoCard.Controls.Add(new Panel { Location = new Point(20, 36), Size = new Size(w - 26, 1), BackColor = CBorder });
|
||||
int iy = 46;
|
||||
foreach (var (t, ok) in new[] {
|
||||
("Dokumente, Desktop, Downloads, Bilder, Videos, Musik", true),
|
||||
("AppData\\Roaming — Outlook, Edge, Chrome, App-Einstellungen", true),
|
||||
("Hintergrundbild, Taskbar-Pins, Startmenü-Layout", true),
|
||||
("Temp-Cache, NTUSER.DAT — nicht übertragen", false),
|
||||
}) { infoCard.Controls.Add(Lbl((ok ? "✓ " : "✗ ") + t, new Point(20, iy), null, ok ? CGreen : CAmber)); iy += 24; }
|
||||
_body2.Controls.Add(infoCard);
|
||||
|
||||
var selCard = MkCard(m, y, w, 108); y += 120;
|
||||
selCard.Controls.Add(new Label { Text = "Profile auswählen", Font = new Font("Segoe UI", 10f, FontStyle.Bold), ForeColor = CTealD, Location = new Point(20, 12), AutoSize = true });
|
||||
selCard.Controls.Add(new Panel { Location = new Point(20, 36), Size = new Size(w - 26, 1), BackColor = CBorder });
|
||||
selCard.Controls.Add(Lbl("Von (altes Entra-Profil):", new Point(20, 46)));
|
||||
selCard.Controls.Add(Lbl("Nach (neues Domain-Profil):", new Point(20, 76)));
|
||||
_cbSrc = Cmb(new Point(210, 43), new Size(w - 230, 24));
|
||||
_cbDst = Cmb(new Point(210, 73), new Size(w - 230, 24));
|
||||
selCard.Controls.Add(_cbSrc); selCard.Controls.Add(_cbDst);
|
||||
_body2.Controls.Add(selCard);
|
||||
|
||||
_btnMig = new RoundButton("▶ Profil jetzt migrieren", CGreen, 10) { Location = new Point(m, y), Size = new Size(440, 44) }; y += 52;
|
||||
_btnMig.Click += DoMig;
|
||||
_lnkBack = new Label { Text = "← Zurück zu Domain Join", Font = new Font("Segoe UI", 9f), ForeColor = CText2, Location = new Point(m + 448, y - 40), AutoSize = true, Cursor = Cursors.Hand };
|
||||
_lnkBack.Click += (s, e) => GoTo(0);
|
||||
_lnkBack.MouseEnter += (s, e) => _lnkBack.ForeColor = CTeal;
|
||||
_lnkBack.MouseLeave += (s, e) => _lnkBack.ForeColor = CText2;
|
||||
_body2.Controls.Add(_btnMig); _body2.Controls.Add(_lnkBack);
|
||||
|
||||
// ── Status + Progress + Log (Tab 2) ──────────────────────────────
|
||||
_lblMigStatus = new Label { Text = "", Font = new Font("Segoe UI", 9f, FontStyle.Bold), ForeColor = CTealD, Location = new Point(m, y), AutoSize = true };
|
||||
_lblMigCount = new Label { Text = "", Font = new Font("Cascadia Mono", 8f), ForeColor = CText2, Location = new Point(m + 300, y), AutoSize = true };
|
||||
y += 22;
|
||||
_pbMig = new ProgressBar { Location = new Point(m, y), Size = new Size(w, 6), Style = ProgressBarStyle.Marquee, MarqueeAnimationSpeed = 20, Visible = false }; y += 14;
|
||||
_logMig = new RichTextBox { Location = new Point(m, y), Size = new Size(w, 106), BackColor = Color.FromArgb(13, 17, 23), ForeColor = Color.FromArgb(100, 210, 130), Font = new Font("Cascadia Mono", 8f), ReadOnly = true, BorderStyle = BorderStyle.None, Visible = false };
|
||||
_body2.Controls.Add(_lblMigStatus); _body2.Controls.Add(_lblMigCount);
|
||||
_body2.Controls.Add(_pbMig); _body2.Controls.Add(_logMig);
|
||||
}
|
||||
|
||||
// ── State ─────────────────────────────────────────────────────────────
|
||||
void DetectStatus()
|
||||
{
|
||||
Task.Run(new Action(() => {
|
||||
try { var r = Run("dsregcmd", "/status", 10000); _entra = r.Contains("AzureAdJoined : YES"); _domain = r.Contains("DomainJoined : YES"); foreach (var ln in r.Split('\n')) if (ln.Trim().StartsWith("DomainName :")) { _dn = ln.Split(':')[1].Trim(); break; } } catch { }
|
||||
Invoke(new Action(() => { RefreshStat(); CheckAutoMig(); }));
|
||||
}));
|
||||
}
|
||||
|
||||
void RefreshStat()
|
||||
{
|
||||
if (_domain) { _badge.Mode = StatusBadge.BMode.Joined; _lblStat.Text = "Domain joined: " + _dn; _lblStat.ForeColor = CGreen; _btnJoin.Text = "↺ Domain wechseln"; _btnLeave.Text = "■ Verlassen"; }
|
||||
else if (_entra) { _badge.Mode = StatusBadge.BMode.Entra; _lblStat.Text = "Entra ID joined — bereit für Domain-Join"; _lblStat.ForeColor = CTealD; }
|
||||
else { _badge.Mode = StatusBadge.BMode.None; _lblStat.Text = "Workgroup — kein Domain-Join"; _lblStat.ForeColor = CAmber; _btnLeave.Enabled = false; }
|
||||
}
|
||||
|
||||
void CheckAutoMig()
|
||||
{
|
||||
if (!File.Exists(FLG) || !_domain) return;
|
||||
try {
|
||||
var old = File.ReadAllText(FLG).Trim(); LoadProfiles();
|
||||
for (int i = 0; i < _cbSrc.Items.Count; i++) if (_cbSrc.Items[i].ToString().Equals(old, StringComparison.OrdinalIgnoreCase)) { _cbSrc.SelectedIndex = i; break; }
|
||||
var cur = Environment.UserName;
|
||||
for (int i = 0; i < _cbDst.Items.Count; i++) if (_cbDst.Items[i].ToString().Equals(cur, StringComparison.OrdinalIgnoreCase)) { _cbDst.SelectedIndex = i; break; }
|
||||
GoTo(1);
|
||||
if (MessageBox.Show("Domain-Join erfolgreich!\n\nProfil migrieren?\n Von: C:\\Users\\" + old + "\n Nach: C:\\Users\\" + cur, "Profil Migration", MessageBoxButtons.YesNo, MessageBoxIcon.Question) == DialogResult.Yes)
|
||||
DoMig(null, EventArgs.Empty);
|
||||
else File.Delete(FLG);
|
||||
} catch { }
|
||||
}
|
||||
|
||||
void LoadProfiles()
|
||||
{
|
||||
if (_cbSrc == null) return;
|
||||
var list = new List<string>();
|
||||
if (Directory.Exists(@"C:\Users")) foreach (var d in Directory.GetDirectories(@"C:\Users")) { var n = Path.GetFileName(d); if (n != "Public" && n != "Default" && n != "Default User" && n != "All Users") list.Add(n); }
|
||||
_cbSrc.Items.Clear(); _cbDst.Items.Clear();
|
||||
foreach (var p in list) { _cbSrc.Items.Add(p); _cbDst.Items.Add(p); }
|
||||
if (_cbSrc.Items.Count > 0) _cbSrc.SelectedIndex = 0;
|
||||
if (_cbDst.Items.Count > 1) _cbDst.SelectedIndex = 1;
|
||||
}
|
||||
|
||||
// ── Actions ───────────────────────────────────────────────────────────
|
||||
async void DoJoin(object s, EventArgs e)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_txUser.Val) || string.IsNullOrWhiteSpace(_txPass.Val)) { MessageBox.Show("Bitte Benutzername und Passwort eingeben.", "Eingabe fehlt", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; }
|
||||
if (MessageBox.Show("Computer wird der Domain '" + DOM + "' beigetreten.\n\nFortfahren?", "Bestätigung", MessageBoxButtons.YesNo, MessageBoxIcon.Question) != DialogResult.Yes) return;
|
||||
Busy(true, "Domain-Wechsel läuft…"); _badge.Mode = StatusBadge.BMode.Working;
|
||||
var user = _txUser.Val.Trim(); var pass = _txPass.Val;
|
||||
await Task.Run(new Action(async () => {
|
||||
if (_entra) { Prog("Entra ID verlassen…"); Log(Run("dsregcmd", "/leave", 30000)); await Task.Delay(2000); }
|
||||
Prog("Domain joinen…");
|
||||
if (!user.Contains("\\") && !user.Contains("@")) user = DOM + "\\" + user;
|
||||
var res = PS("$pw=ConvertTo-SecureString '" + Esc(pass) + "' -AsPlainText -Force; $cr=New-Object System.Management.Automation.PSCredential('" + Esc(user) + "',$pw); Add-Computer -DomainName '" + DOM + "' -Credential $cr -Force 2>&1");
|
||||
Log(res);
|
||||
var ok = !res.ToLower().Contains("fehler") && !res.ToLower().Contains("error") && !res.ToLower().Contains("failed") && !res.ToLower().Contains("denied");
|
||||
if (ok) SetupAutoMig();
|
||||
Invoke(new Action(() => {
|
||||
Busy(false, "");
|
||||
if (ok) { if (MessageBox.Show("Erfolgreich!\n\nJetzt neu starten?", "✓ Fertig", MessageBoxButtons.YesNo, MessageBoxIcon.Information) == DialogResult.Yes) Run("shutdown", "/r /t 5 /c \"Domain Join\""); }
|
||||
else MessageBox.Show("Fehlgeschlagen — Log prüfen.", "Fehler", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
DetectStatus();
|
||||
}));
|
||||
}));
|
||||
}
|
||||
|
||||
async void DoLeave(object s, EventArgs e)
|
||||
{
|
||||
if (MessageBox.Show("Computer verlässt " + (_domain ? "Domain '" + _dn + "'" : "Entra ID") + ".\n\nFortfahren?", "Bestätigung", MessageBoxButtons.YesNo, MessageBoxIcon.Question) != DialogResult.Yes) return;
|
||||
Busy(true, "Verlasse…"); _badge.Mode = StatusBadge.BMode.Working;
|
||||
await Task.Run(new Action(() => { Log(Run("dsregcmd", "/leave", 30000)); Invoke(new Action(() => { Busy(false, ""); MessageBox.Show("Fertig. Bitte neu starten.", "Erledigt", MessageBoxButtons.OK, MessageBoxIcon.Information); DetectStatus(); })); }));
|
||||
}
|
||||
|
||||
void SetupAutoMig()
|
||||
{
|
||||
try { Directory.CreateDirectory(Path.GetDirectoryName(FLG)); File.WriteAllText(FLG, Environment.UserName); File.Copy(System.Reflection.Assembly.GetExecutingAssembly().Location, EXE, true); using (var k = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce", true)) k.SetValue("DomainJoinMigration", "\"" + EXE + "\""); Log("[AUTO] Migration startet nach nächstem Login automatisch"); } catch (Exception ex) { Log("[WARN] " + ex.Message); }
|
||||
}
|
||||
|
||||
async void DoMig(object s, EventArgs e)
|
||||
{
|
||||
if (_cbSrc.SelectedItem == null || _cbDst.SelectedItem == null)
|
||||
{
|
||||
MessageBox.Show("Bitte Quell- und Zielprofil auswählen.", "Profil fehlt", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
return;
|
||||
}
|
||||
var src = _cbSrc.SelectedItem.ToString();
|
||||
var dst = _cbDst.SelectedItem.ToString();
|
||||
if (src == dst) { MessageBox.Show("Quelle und Ziel sind identisch.", "Fehler", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; }
|
||||
var srcPath = @"C:\Users\" + src;
|
||||
var dstPath = @"C:\Users\" + dst;
|
||||
if (!Directory.Exists(srcPath)) { MessageBox.Show("Quellprofil nicht gefunden:\n" + srcPath, "Fehler", MessageBoxButtons.OK, MessageBoxIcon.Error); return; }
|
||||
if (MessageBox.Show("Profil kopieren?\n\nVon: " + srcPath + "\nNach: " + dstPath + "\n\nDas kann mehrere Minuten dauern.", "Bestätigung", MessageBoxButtons.YesNo, MessageBoxIcon.Question) != DialogResult.Yes) return;
|
||||
|
||||
_btnMig.Enabled = false;
|
||||
_logMig.Visible = true;
|
||||
_logMig.Clear();
|
||||
_pbMig.Visible = true;
|
||||
_lblMigCount.Text = "";
|
||||
MigLog("[START] " + src + " → " + dst);
|
||||
MigStatus("⏳ Migration läuft…");
|
||||
Log("[START] Profil-Migration: " + src + " → " + dst);
|
||||
|
||||
await Task.Run(new Action(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(dstPath))
|
||||
{
|
||||
Directory.CreateDirectory(dstPath);
|
||||
MigLog("[INFO] Zielordner angelegt: " + dstPath);
|
||||
}
|
||||
|
||||
var args = "\"" + srcPath + "\" \"" + dstPath + "\""
|
||||
+ " /E /XJ /XC /XN /XO"
|
||||
+ " /XA:O"
|
||||
+ " /XD Temp INetCache WebCache \"AppData\\Local\\Temp\" OneDrive \"Cereda Systems GmbH\" \"OneDrive - Cereda Systems GmbH\""
|
||||
+ " /XF NTUSER.DAT ntuser.dat ntuser.ini \"*.regtrans-ms\" \"*.blf\""
|
||||
+ " /R:0 /W:0 /NP";
|
||||
|
||||
MigLog("[CMD] robocopy " + args);
|
||||
var psi = new System.Diagnostics.ProcessStartInfo(
|
||||
@"C:\Windows\System32\robocopy.exe", args)
|
||||
{
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
int exitCode = -1;
|
||||
int copiedFiles = 0, skippedFiles = 0;
|
||||
using (var proc = System.Diagnostics.Process.Start(psi))
|
||||
{
|
||||
string line;
|
||||
while ((line = proc.StandardOutput.ReadLine()) != null)
|
||||
{
|
||||
MigLog(line);
|
||||
// Count copied files ("Neue Datei" or "New File")
|
||||
if (line.Contains("Neue Datei") || line.Contains("New File"))
|
||||
{
|
||||
copiedFiles++;
|
||||
MigCount("Kopiert: " + copiedFiles + " Dateien" + (skippedFiles > 0 ? " • Übersprungen: " + skippedFiles : ""));
|
||||
}
|
||||
else if (line.Contains("FEHLER") || line.Contains("ERROR"))
|
||||
{
|
||||
skippedFiles++;
|
||||
MigCount("Kopiert: " + copiedFiles + " Dateien • Übersprungen: " + skippedFiles);
|
||||
}
|
||||
}
|
||||
proc.WaitForExit(1800000);
|
||||
exitCode = proc.ExitCode;
|
||||
}
|
||||
Invoke(new Action(() => _pbMig.Visible = false));
|
||||
// robocopy: 0-7 = OK (0=nichts zu tun, 1=kopiert, 2=extra, etc.), 8+ = Fehler
|
||||
if (exitCode >= 8)
|
||||
{
|
||||
MigLog("[FEHLER] ExitCode=" + exitCode);
|
||||
MigStatus("✗ Fehler (ExitCode " + exitCode + ")");
|
||||
}
|
||||
else
|
||||
{
|
||||
MigLog("[OK] Abgeschlossen. ExitCode=" + exitCode);
|
||||
MigStatus("✓ Migration abgeschlossen — " + copiedFiles + " Dateien kopiert");
|
||||
}
|
||||
|
||||
try { if (File.Exists(FLG)) File.Delete(FLG); } catch { }
|
||||
|
||||
Invoke(new Action(() =>
|
||||
{
|
||||
_btnMig.Enabled = true;
|
||||
MessageBox.Show("Migration abgeschlossen!\n\nDateien sind in:\n" + dstPath,
|
||||
"✓ Fertig", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||
}));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MigLog("[EXCEPTION] " + ex.Message);
|
||||
MigStatus("✗ Fehler aufgetreten");
|
||||
Log("[EXCEPTION] Migration: " + ex.Message);
|
||||
Invoke(new Action(() =>
|
||||
{
|
||||
_btnMig.Enabled = true;
|
||||
MessageBox.Show("Fehler bei Migration:\n\n" + ex.Message, "Fehler",
|
||||
MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
}));
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
void Busy(bool b, string m) { if (InvokeRequired) { Invoke(new Action(() => Busy(b, m))); return; } _btnJoin.Enabled = _btnLeave.Enabled = !b; _pb.Visible = _lbPb.Visible = b; if (!string.IsNullOrEmpty(m)) _lbPb.Text = m; }
|
||||
void Prog(string m) { if (InvokeRequired) { Invoke(new Action(() => Prog(m))); return; } _lbPb.Text = m; Log(m); }
|
||||
void Log(string t) { if (InvokeRequired) { Invoke(new Action(() => Log(t))); return; } if (string.IsNullOrWhiteSpace(t)) return; var ln = "[" + DateTime.Now.ToString("HH:mm:ss") + "] " + t.Trim(); _log?.AppendText(ln + "\n"); _log?.ScrollToCaret(); try { Directory.CreateDirectory(Path.GetDirectoryName(LOG)); File.AppendAllText(LOG, DateTime.Now.ToString("yyyy-MM-dd") + " " + ln + Environment.NewLine); } catch { } }
|
||||
void MigLog(string t) { if (InvokeRequired) { Invoke(new Action(() => MigLog(t))); return; } if (string.IsNullOrWhiteSpace(t)) return; _logMig?.AppendText(t.TrimEnd() + "\n"); _logMig?.ScrollToCaret(); }
|
||||
void MigStatus(string t) { if (InvokeRequired) { Invoke(new Action(() => MigStatus(t))); return; } if (_lblMigStatus != null) { _lblMigStatus.Text = t; _lblMigStatus.ForeColor = t.StartsWith("✓") ? CGreen : t.StartsWith("✗") ? CRed : CTealD; } }
|
||||
void MigCount(string t) { if (InvokeRequired) { Invoke(new Action(() => MigCount(t))); return; } if (_lblMigCount != null) _lblMigCount.Text = t; }
|
||||
|
||||
// Same pattern as ZeloServerVerwaltung
|
||||
Panel MkCard(int x, int y, int w, int h)
|
||||
{
|
||||
var p = new Panel { Location = new Point(x, y), Size = new Size(w, h), BackColor = Color.White };
|
||||
var accent = new Panel { Location = Point.Empty, Size = new Size(5, h), BackColor = CTeal };
|
||||
p.Controls.Add(accent);
|
||||
p.Paint += (s, e) =>
|
||||
{
|
||||
var g = e.Graphics; g.SmoothingMode = SmoothingMode.AntiAlias;
|
||||
// Border
|
||||
using (var path = RR(1, 1, w - 2, h - 2, 10))
|
||||
using (var pen = new Pen(Color.FromArgb(215, 225, 235), 1f)) g.DrawPath(pen, path);
|
||||
// Subtle bottom shadow line
|
||||
using (var pen = new Pen(Color.FromArgb(50, 0, 100, 150), 1.5f))
|
||||
g.DrawLine(pen, 10, h - 1, w - 4, h - 1);
|
||||
};
|
||||
using (var path = RR(0, 0, w, h, 10))
|
||||
p.Region = new Region(path);
|
||||
return p;
|
||||
}
|
||||
|
||||
Label Lbl(string t, Point loc, Font f = null, Color? c = null) => new Label { Text = t, Font = f ?? new Font("Segoe UI", 9f), ForeColor = c ?? CText2, Location = loc, AutoSize = true };
|
||||
ModernInput Inp(Point loc, Size sz, string placeholder = "", bool pw = false)
|
||||
{
|
||||
return new ModernInput(placeholder, pw) { Location = loc, Size = sz };
|
||||
}
|
||||
ComboBox Cmb(Point loc, Size sz) => new ComboBox { Location = loc, Size = sz, DropDownStyle = ComboBoxStyle.DropDownList, FlatStyle = FlatStyle.Flat, Font = new Font("Segoe UI", 9f) };
|
||||
|
||||
static GraphicsPath RR(int x, int y, int w, int h, int r) { var p = new GraphicsPath(); p.AddArc(x, y, r * 2, r * 2, 180, 90); p.AddArc(x + w - r * 2, y, r * 2, r * 2, 270, 90); p.AddArc(x + w - r * 2, y + h - r * 2, r * 2, r * 2, 0, 90); p.AddArc(x, y + h - r * 2, r * 2, r * 2, 90, 90); p.CloseFigure(); return p; }
|
||||
static Icon MakeIcon()
|
||||
{
|
||||
var bmp = new Bitmap(32, 32);
|
||||
using (var g = Graphics.FromImage(bmp))
|
||||
{
|
||||
g.SmoothingMode = SmoothingMode.AntiAlias;
|
||||
g.TextRenderingHint = TextRenderingHint.ClearTypeGridFit;
|
||||
// Teal rounded square
|
||||
using (var path = RR(0, 0, 32, 32, 6))
|
||||
using (var br = new SolidBrush(ColorTranslator.FromHtml("#0D9488")))
|
||||
g.FillPath(br, path);
|
||||
// White "C" letter
|
||||
var sf = new StringFormat { Alignment = StringAlignment.Center, LineAlignment = StringAlignment.Center };
|
||||
g.DrawString("C", new Font("Segoe UI", 16f, FontStyle.Bold), Brushes.White, new RectangleF(0, 0, 32, 32), sf);
|
||||
}
|
||||
IntPtr hIcon = bmp.GetHicon();
|
||||
return Icon.FromHandle(hIcon);
|
||||
}
|
||||
|
||||
string Run(string exe, string args, int ms = 60000) { var psi = new ProcessStartInfo(exe, args) { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true }; using (var p = Process.Start(psi)) { var o = p.StandardOutput.ReadToEnd() + p.StandardError.ReadToEnd(); p.WaitForExit(ms); return o; } }
|
||||
string PS(string sc) => Run("powershell.exe", "-NoProfile -NonInteractive -ExecutionPolicy Bypass -Command \"" + sc.Replace("\"", "\\\"") + "\"", 120000);
|
||||
static string Esc(string s) => s.Replace("'", "''");
|
||||
|
||||
protected override void OnFormClosed(FormClosedEventArgs e) { _animTimer?.Stop(); _animTimer?.Dispose(); base.OnFormClosed(e); }
|
||||
|
||||
// ── ModernInput — Floating-Label + Fokus-Effekt + Validierung ─────────
|
||||
class ModernInput : Panel
|
||||
{
|
||||
readonly Label _lbl;
|
||||
readonly TextBox _txt;
|
||||
readonly Label _icon;
|
||||
bool _focused, _ready;
|
||||
static readonly Color Teal = ColorTranslator.FromHtml("#0D9488");
|
||||
static readonly Color Gray = ColorTranslator.FromHtml("#94A3B8");
|
||||
static readonly Color Border = ColorTranslator.FromHtml("#E2E8F0");
|
||||
static readonly Color Green = ColorTranslator.FromHtml("#059669");
|
||||
static readonly Color Red = ColorTranslator.FromHtml("#DC2626");
|
||||
|
||||
public string Val { get => _txt.Text; set { _txt.Text = value; Update_(); } }
|
||||
|
||||
public ModernInput(string placeholder, bool pw = false)
|
||||
{
|
||||
DoubleBuffered = true;
|
||||
SetStyle(ControlStyles.OptimizedDoubleBuffer | ControlStyles.AllPaintingInWmPaint |
|
||||
ControlStyles.UserPaint | ControlStyles.ResizeRedraw, true);
|
||||
BackColor = Color.Transparent; Height = 52;
|
||||
|
||||
_lbl = new Label {
|
||||
Text = placeholder,
|
||||
Font = new Font("Segoe UI", 9.5f),
|
||||
ForeColor = Gray,
|
||||
Location = new Point(13, 15),
|
||||
AutoSize = true
|
||||
};
|
||||
|
||||
_txt = new TextBox {
|
||||
BorderStyle = BorderStyle.None,
|
||||
Location = new Point(13, 28),
|
||||
Font = new Font("Segoe UI", 10.5f),
|
||||
BackColor = Color.White
|
||||
};
|
||||
if (pw) _txt.PasswordChar = '●';
|
||||
|
||||
// Validation icon
|
||||
_icon = new Label {
|
||||
Font = new Font("Segoe UI", 12f),
|
||||
Text = "",
|
||||
AutoSize = true,
|
||||
ForeColor = Gray
|
||||
};
|
||||
|
||||
_txt.GotFocus += (s, e) => { _focused = true; Update_(); Invalidate(); };
|
||||
_txt.LostFocus += (s, e) => { _focused = false; Update_(); Invalidate(); };
|
||||
_txt.TextChanged += (s, e) => { Update_(); Invalidate(); };
|
||||
|
||||
Controls.Add(_lbl);
|
||||
Controls.Add(_txt);
|
||||
Controls.Add(_icon);
|
||||
_ready = true;
|
||||
}
|
||||
|
||||
void Update_()
|
||||
{
|
||||
bool fl = _focused || !string.IsNullOrEmpty(_txt.Text);
|
||||
_lbl.Font = new Font("Segoe UI", fl ? 7.5f : 9.5f);
|
||||
_lbl.Location = new Point(13, fl ? 6 : 15);
|
||||
_lbl.ForeColor = _focused ? Teal : Gray;
|
||||
|
||||
bool filled = !string.IsNullOrWhiteSpace(_txt.Text);
|
||||
_icon.Text = filled ? "✓" : "";
|
||||
_icon.ForeColor = filled ? Green : Gray;
|
||||
if (_icon.Width > 0)
|
||||
_icon.Location = new Point(Width - _icon.Width - 12, (Height - _icon.Height) / 2);
|
||||
}
|
||||
|
||||
protected override void OnResize(EventArgs e)
|
||||
{
|
||||
base.OnResize(e);
|
||||
if (!_ready || _txt == null) return;
|
||||
_txt.Width = Width - 32;
|
||||
Update_();
|
||||
}
|
||||
|
||||
protected override void OnPaint(PaintEventArgs e)
|
||||
{
|
||||
var g = e.Graphics;
|
||||
g.SmoothingMode = SmoothingMode.AntiAlias;
|
||||
|
||||
// White background
|
||||
using (var br = new SolidBrush(Color.White))
|
||||
g.FillRectangle(br, 0, 0, Width, Height);
|
||||
|
||||
// Border
|
||||
var bc = _focused ? Teal : Border;
|
||||
using (var pen = new Pen(bc, _focused ? 2f : 1f))
|
||||
g.DrawRectangle(pen, 0, 0, Width - 1, Height - 1);
|
||||
|
||||
// Bottom accent line on focus
|
||||
if (_focused)
|
||||
using (var pen = new Pen(Teal, 2.5f))
|
||||
g.DrawLine(pen, 2, Height - 1, Width - 3, Height - 1);
|
||||
}
|
||||
}
|
||||
|
||||
// ── RoundButton (same as ZeloServerVerwaltung) ────────────────────────
|
||||
class RoundButton : Control
|
||||
{
|
||||
bool _hov, _press; readonly Color _base, _hov2, _press2; readonly int _r;
|
||||
public RoundButton(string text, Color baseColor, int radius = 8)
|
||||
{
|
||||
_base = baseColor; _hov2 = ControlPaint.Dark(baseColor, 0.10f); _press2 = ControlPaint.Dark(baseColor, 0.20f); _r = radius;
|
||||
Text = text; Font = new Font("Segoe UI", 9f, FontStyle.Bold); Cursor = Cursors.Hand;
|
||||
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer, true);
|
||||
}
|
||||
protected override void OnPaint(PaintEventArgs e)
|
||||
{
|
||||
var g = e.Graphics; g.SmoothingMode = SmoothingMode.AntiAlias;
|
||||
g.Clear(Parent?.BackColor ?? Color.White);
|
||||
var fill = _press ? _press2 : (_hov && Enabled) ? _hov2 : _base;
|
||||
using (var path = RR(0, 0, Width, Height, _r)) using (var br = new SolidBrush(fill)) g.FillPath(br, path);
|
||||
if (!Enabled) using (var path = RR(0, 0, Width, Height, _r)) using (var br = new SolidBrush(Color.FromArgb(110, 255, 255, 255))) g.FillPath(br, path);
|
||||
TextRenderer.DrawText(e.Graphics, Text, Font, new Rectangle(0, 0, Width, Height), Color.White, TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
|
||||
}
|
||||
protected override void OnMouseEnter(EventArgs e) { _hov = true; Invalidate(); base.OnMouseEnter(e); }
|
||||
protected override void OnMouseLeave(EventArgs e) { _hov = false; _press = false; Invalidate(); base.OnMouseLeave(e); }
|
||||
protected override void OnMouseDown(MouseEventArgs e) { if (e.Button == MouseButtons.Left) { _press = true; Invalidate(); } base.OnMouseDown(e); }
|
||||
protected override void OnMouseUp(MouseEventArgs e) { _press = false; Invalidate(); base.OnMouseUp(e); }
|
||||
protected override void OnEnabledChanged(EventArgs e) { Cursor = Enabled ? Cursors.Hand : Cursors.Default; Invalidate(); base.OnEnabledChanged(e); }
|
||||
}
|
||||
|
||||
// ── StatusBadge (adapted from ZeloServerVerwaltung) ───────────────────
|
||||
class StatusBadge : Control
|
||||
{
|
||||
public enum BMode { Entra, Joined, None, Working }
|
||||
BMode _mode; float _phase, _spin;
|
||||
public BMode Mode { get => _mode; set { _mode = value; Invalidate(); } }
|
||||
public StatusBadge() { SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer, true); Size = new Size(230, 28); BackColor = Color.White; }
|
||||
public void Tick(float ph, float sp) { _phase = ph; _spin = sp; if (_mode != BMode.None) Invalidate(); }
|
||||
protected override void OnPaint(PaintEventArgs e)
|
||||
{
|
||||
var g = e.Graphics; g.SmoothingMode = SmoothingMode.AntiAlias; g.TextRenderingHint = TextRenderingHint.ClearTypeGridFit; g.Clear(Color.White);
|
||||
Color bg; string txt;
|
||||
switch (_mode) {
|
||||
case BMode.Joined: bg = ColorTranslator.FromHtml("#059669"); txt = "DOMAIN JOINED"; break;
|
||||
case BMode.Entra: bg = ColorTranslator.FromHtml("#0D9488"); txt = "ENTRA ID JOINED"; break;
|
||||
case BMode.Working: bg = ColorTranslator.FromHtml("#D97706"); txt = "BITTE WARTEN..."; break;
|
||||
default: bg = ColorTranslator.FromHtml("#6B7280"); txt = "WORKGROUP"; break;
|
||||
}
|
||||
int r = Height / 2;
|
||||
using (var path = new GraphicsPath()) { path.AddArc(0, 0, r * 2, r * 2, 90, 180); path.AddArc(Width - r * 2, 0, r * 2, r * 2, 270, 180); path.CloseFigure(); using (var br = new SolidBrush(bg)) g.FillPath(br, path); }
|
||||
// Dot
|
||||
int dx = r, dy = r, dr = 5;
|
||||
if (_mode == BMode.Working) { using (var p2 = new Pen(Color.FromArgb(60, Color.White), 2f)) g.DrawEllipse(p2, dx - dr, dy - dr, dr * 2, dr * 2); using (var p2 = new Pen(Color.White, 2f) { StartCap = LineCap.Round, EndCap = LineCap.Round }) g.DrawArc(p2, dx - dr, dy - dr, dr * 2, dr * 2, _spin, 100f); }
|
||||
else { double pv = Math.Sin(_phase * Math.PI * 2) * 0.5 + 0.5; int gr = dr + (int)(pv * 2); using (var b = new SolidBrush(Color.FromArgb(60, Color.White))) g.FillEllipse(b, dx - gr, dy - gr, gr * 2, gr * 2); using (var b = new SolidBrush(Color.White)) g.FillEllipse(b, dx - dr, dy - dr, dr * 2, dr * 2); }
|
||||
// Text
|
||||
var rect = new RectangleF(r * 2 + 6, 0, Width - r * 2 - 6 - r, Height);
|
||||
var sf = new StringFormat { Alignment = StringAlignment.Near, LineAlignment = StringAlignment.Center };
|
||||
using (var f = new Font("Segoe UI", 8.5f, FontStyle.Bold)) using (var br = new SolidBrush(Color.White)) g.DrawString(txt, f, br, rect, sf);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
using System;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace DomainJoinTool
|
||||
{
|
||||
internal static class Program
|
||||
{
|
||||
[STAThread]
|
||||
static void Main()
|
||||
{
|
||||
Application.EnableVisualStyles();
|
||||
Application.SetCompatibleTextRenderingDefault(false);
|
||||
Application.SetUnhandledExceptionMode(UnhandledExceptionMode.CatchException);
|
||||
Application.ThreadException += (s, e) =>
|
||||
System.IO.File.AppendAllText(@"C:\ProgramData\DomainJoinTool\crash.log",
|
||||
DateTime.Now + ": " + e.Exception + Environment.NewLine);
|
||||
AppDomain.CurrentDomain.UnhandledException += (s, e) =>
|
||||
System.IO.File.AppendAllText(@"C:\ProgramData\DomainJoinTool\crash.log",
|
||||
DateTime.Now + ": " + e.ExceptionObject + Environment.NewLine);
|
||||
try
|
||||
{
|
||||
Application.Run(new MainForm());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show("Startfehler: " + ex.Message + "\n\n" + ex.StackTrace, "Fehler");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<assemblyIdentity version="1.0.0.0" name="DomainJoinTool.app"/>
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
|
||||
<security>
|
||||
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<!-- Braucht Admin-Rechte für dsregcmd und Add-Computer -->
|
||||
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
|
||||
</requestedPrivileges>
|
||||
</security>
|
||||
</trustInfo>
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
|
||||
</application>
|
||||
</compatibility>
|
||||
</assembly>
|
||||
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<configuration>
|
||||
<startup>
|
||||
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.7.2" />
|
||||
</startup>
|
||||
</configuration>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 17 KiB |
@@ -1,63 +0,0 @@
|
||||
{
|
||||
"format": 1,
|
||||
"restore": {
|
||||
"C:\\Users\\gruessing\\OneDrive - Cereda Systems GmbH\\Desktop\\IT Tool\\domain-join-tool\\DomainJoinTool.csproj": {}
|
||||
},
|
||||
"projects": {
|
||||
"C:\\Users\\gruessing\\OneDrive - Cereda Systems GmbH\\Desktop\\IT Tool\\domain-join-tool\\DomainJoinTool.csproj": {
|
||||
"version": "1.0.0",
|
||||
"restore": {
|
||||
"projectUniqueName": "C:\\Users\\gruessing\\OneDrive - Cereda Systems GmbH\\Desktop\\IT Tool\\domain-join-tool\\DomainJoinTool.csproj",
|
||||
"projectName": "DomainJoinTool",
|
||||
"projectPath": "C:\\Users\\gruessing\\OneDrive - Cereda Systems GmbH\\Desktop\\IT Tool\\domain-join-tool\\DomainJoinTool.csproj",
|
||||
"packagesPath": "C:\\Users\\gruessing\\.nuget\\packages\\",
|
||||
"outputPath": "C:\\Users\\gruessing\\OneDrive - Cereda Systems GmbH\\Desktop\\IT Tool\\domain-join-tool\\obj\\",
|
||||
"projectStyle": "PackageReference",
|
||||
"configFilePaths": [
|
||||
"C:\\Users\\gruessing\\AppData\\Roaming\\NuGet\\NuGet.Config"
|
||||
],
|
||||
"originalTargetFrameworks": [
|
||||
"net472"
|
||||
],
|
||||
"sources": {
|
||||
"https://api.nuget.org/v3/index.json": {}
|
||||
},
|
||||
"frameworks": {
|
||||
"net472": {
|
||||
"targetAlias": "net472",
|
||||
"projectReferences": {}
|
||||
}
|
||||
},
|
||||
"warningProperties": {
|
||||
"warnAsError": [
|
||||
"NU1605"
|
||||
]
|
||||
},
|
||||
"restoreAuditProperties": {
|
||||
"enableAudit": "true",
|
||||
"auditLevel": "low",
|
||||
"auditMode": "direct"
|
||||
}
|
||||
},
|
||||
"frameworks": {
|
||||
"net472": {
|
||||
"targetAlias": "net472",
|
||||
"dependencies": {
|
||||
"Microsoft.NETFramework.ReferenceAssemblies": {
|
||||
"suppressParent": "All",
|
||||
"target": "Package",
|
||||
"version": "[1.0.3, )",
|
||||
"autoReferenced": true
|
||||
}
|
||||
},
|
||||
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\8.0.421\\RuntimeIdentifierGraph.json"
|
||||
}
|
||||
},
|
||||
"runtimes": {
|
||||
"win7-x64": {
|
||||
"#import": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8" standalone="no"?>
|
||||
<Project ToolsVersion="14.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<PropertyGroup Condition=" '$(ExcludeRestorePackageImports)' != 'true' ">
|
||||
<RestoreSuccess Condition=" '$(RestoreSuccess)' == '' ">True</RestoreSuccess>
|
||||
<RestoreTool Condition=" '$(RestoreTool)' == '' ">NuGet</RestoreTool>
|
||||
<ProjectAssetsFile Condition=" '$(ProjectAssetsFile)' == '' ">$(MSBuildThisFileDirectory)project.assets.json</ProjectAssetsFile>
|
||||
<NuGetPackageRoot Condition=" '$(NuGetPackageRoot)' == '' ">$(UserProfile)\.nuget\packages\</NuGetPackageRoot>
|
||||
<NuGetPackageFolders Condition=" '$(NuGetPackageFolders)' == '' ">C:\Users\gruessing\.nuget\packages\</NuGetPackageFolders>
|
||||
<NuGetProjectStyle Condition=" '$(NuGetProjectStyle)' == '' ">PackageReference</NuGetProjectStyle>
|
||||
<NuGetToolVersion Condition=" '$(NuGetToolVersion)' == '' ">6.11.2</NuGetToolVersion>
|
||||
</PropertyGroup>
|
||||
<ItemGroup Condition=" '$(ExcludeRestorePackageImports)' != 'true' ">
|
||||
<SourceRoot Include="C:\Users\gruessing\.nuget\packages\" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8" standalone="no"?>
|
||||
<Project ToolsVersion="14.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<ImportGroup Condition=" '$(ExcludeRestorePackageImports)' != 'true' ">
|
||||
<Import Project="$(NuGetPackageRoot)microsoft.netframework.referenceassemblies.net472\1.0.3\build\Microsoft.NETFramework.ReferenceAssemblies.net472.targets" Condition="Exists('$(NuGetPackageRoot)microsoft.netframework.referenceassemblies.net472\1.0.3\build\Microsoft.NETFramework.ReferenceAssemblies.net472.targets')" />
|
||||
</ImportGroup>
|
||||
</Project>
|
||||
@@ -1,4 +0,0 @@
|
||||
// <autogenerated />
|
||||
using System;
|
||||
using System.Reflection;
|
||||
[assembly: global::System.Runtime.Versioning.TargetFrameworkAttribute(".NETFramework,Version=v4.7.2", FrameworkDisplayName = ".NET Framework 4.7.2")]
|
||||
@@ -1,22 +0,0 @@
|
||||
//------------------------------------------------------------------------------
|
||||
// <auto-generated>
|
||||
// This code was generated by a tool.
|
||||
//
|
||||
// Changes to this file may cause incorrect behavior and will be lost if
|
||||
// the code is regenerated.
|
||||
// </auto-generated>
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Reflection;
|
||||
|
||||
[assembly: System.Reflection.AssemblyCompanyAttribute("DomainJoinTool")]
|
||||
[assembly: System.Reflection.AssemblyConfigurationAttribute("Release")]
|
||||
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0")]
|
||||
[assembly: System.Reflection.AssemblyProductAttribute("DomainJoinTool")]
|
||||
[assembly: System.Reflection.AssemblyTitleAttribute("DomainJoinTool")]
|
||||
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
||||
|
||||
// Von der MSBuild WriteCodeFragment-Klasse generiert.
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
d0c739f07a28fc8609fc4a098983a9d7bd13f38d051a4df2bc76519c63de7a08
|
||||
@@ -1,11 +0,0 @@
|
||||
is_global = true
|
||||
build_property.ApplicationManifest = app.manifest
|
||||
build_property.StartupObject =
|
||||
build_property.ApplicationDefaultFont =
|
||||
build_property.ApplicationHighDpiMode =
|
||||
build_property.ApplicationUseCompatibleTextRendering =
|
||||
build_property.ApplicationVisualStyles =
|
||||
build_property.RootNamespace = DomainJoinTool
|
||||
build_property.ProjectDir = C:\Users\gruessing\OneDrive - Cereda Systems GmbH\Desktop\IT Tool\domain-join-tool\
|
||||
build_property.EnableComHosting =
|
||||
build_property.EnableGeneratedComInterfaceComImportInterop =
|
||||
Binary file not shown.
Binary file not shown.
@@ -1 +0,0 @@
|
||||
074cd1570727f0b97007ce35e18f8b37cb149bef28991a72fe6b298b163c25a9
|
||||
@@ -1,8 +0,0 @@
|
||||
C:\Users\gruessing\OneDrive - Cereda Systems GmbH\Desktop\IT Tool\domain-join-tool\obj\Release\net472\DomainJoinTool.csproj.AssemblyReference.cache
|
||||
C:\Users\gruessing\OneDrive - Cereda Systems GmbH\Desktop\IT Tool\domain-join-tool\obj\Release\net472\DomainJoinTool.GeneratedMSBuildEditorConfig.editorconfig
|
||||
C:\Users\gruessing\OneDrive - Cereda Systems GmbH\Desktop\IT Tool\domain-join-tool\obj\Release\net472\DomainJoinTool.AssemblyInfoInputs.cache
|
||||
C:\Users\gruessing\OneDrive - Cereda Systems GmbH\Desktop\IT Tool\domain-join-tool\obj\Release\net472\DomainJoinTool.AssemblyInfo.cs
|
||||
C:\Users\gruessing\OneDrive - Cereda Systems GmbH\Desktop\IT Tool\domain-join-tool\obj\Release\net472\DomainJoinTool.csproj.CoreCompileInputs.cache
|
||||
C:\Users\gruessing\OneDrive - Cereda Systems GmbH\Desktop\IT Tool\domain-join-tool\bin\Release\net472\DomainJoinTool.exe.config
|
||||
C:\Users\gruessing\OneDrive - Cereda Systems GmbH\Desktop\IT Tool\domain-join-tool\bin\Release\net472\DomainJoinTool.exe
|
||||
C:\Users\gruessing\OneDrive - Cereda Systems GmbH\Desktop\IT Tool\domain-join-tool\obj\Release\net472\DomainJoinTool.exe
|
||||
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<configuration>
|
||||
<startup>
|
||||
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.7.2" />
|
||||
</startup>
|
||||
</configuration>
|
||||
@@ -1,489 +0,0 @@
|
||||
{
|
||||
"version": 3,
|
||||
"targets": {
|
||||
".NETFramework,Version=v4.7.2": {
|
||||
"Microsoft.NETFramework.ReferenceAssemblies/1.0.3": {
|
||||
"type": "package",
|
||||
"dependencies": {
|
||||
"Microsoft.NETFramework.ReferenceAssemblies.net472": "1.0.3"
|
||||
}
|
||||
},
|
||||
"Microsoft.NETFramework.ReferenceAssemblies.net472/1.0.3": {
|
||||
"type": "package",
|
||||
"build": {
|
||||
"build/Microsoft.NETFramework.ReferenceAssemblies.net472.targets": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
".NETFramework,Version=v4.7.2/win7-x64": {
|
||||
"Microsoft.NETFramework.ReferenceAssemblies/1.0.3": {
|
||||
"type": "package",
|
||||
"dependencies": {
|
||||
"Microsoft.NETFramework.ReferenceAssemblies.net472": "1.0.3"
|
||||
}
|
||||
},
|
||||
"Microsoft.NETFramework.ReferenceAssemblies.net472/1.0.3": {
|
||||
"type": "package",
|
||||
"build": {
|
||||
"build/Microsoft.NETFramework.ReferenceAssemblies.net472.targets": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"libraries": {
|
||||
"Microsoft.NETFramework.ReferenceAssemblies/1.0.3": {
|
||||
"sha512": "vUc9Npcs14QsyOD01tnv/m8sQUnGTGOw1BCmKcv77LBJY7OxhJ+zJF7UD/sCL3lYNFuqmQEVlkfS4Quif6FyYg==",
|
||||
"type": "package",
|
||||
"path": "microsoft.netframework.referenceassemblies/1.0.3",
|
||||
"files": [
|
||||
".nupkg.metadata",
|
||||
".signature.p7s",
|
||||
"microsoft.netframework.referenceassemblies.1.0.3.nupkg.sha512",
|
||||
"microsoft.netframework.referenceassemblies.nuspec"
|
||||
]
|
||||
},
|
||||
"Microsoft.NETFramework.ReferenceAssemblies.net472/1.0.3": {
|
||||
"sha512": "0E7evZXHXaDYYiLRfpyXvCh+yzM2rNTyuZDI+ZO7UUqSc6GfjePiXTdqJGtgIKUwdI81tzQKmaWprnUiPj9hAw==",
|
||||
"type": "package",
|
||||
"path": "microsoft.netframework.referenceassemblies.net472/1.0.3",
|
||||
"files": [
|
||||
".nupkg.metadata",
|
||||
".signature.p7s",
|
||||
"build/.NETFramework/v4.7.2/Accessibility.dll",
|
||||
"build/.NETFramework/v4.7.2/Accessibility.xml",
|
||||
"build/.NETFramework/v4.7.2/CustomMarshalers.dll",
|
||||
"build/.NETFramework/v4.7.2/CustomMarshalers.xml",
|
||||
"build/.NETFramework/v4.7.2/Facades/Microsoft.Win32.Primitives.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.AppContext.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Collections.Concurrent.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Collections.NonGeneric.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Collections.Specialized.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Collections.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.ComponentModel.Annotations.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.ComponentModel.EventBasedAsync.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.ComponentModel.Primitives.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.ComponentModel.TypeConverter.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.ComponentModel.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Console.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Data.Common.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Diagnostics.Contracts.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Diagnostics.Debug.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Diagnostics.FileVersionInfo.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Diagnostics.Process.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Diagnostics.StackTrace.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Diagnostics.TextWriterTraceListener.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Diagnostics.Tools.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Diagnostics.TraceSource.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Drawing.Primitives.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Dynamic.Runtime.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Globalization.Calendars.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Globalization.Extensions.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Globalization.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.IO.Compression.ZipFile.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.IO.FileSystem.DriveInfo.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.IO.FileSystem.Primitives.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.IO.FileSystem.Watcher.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.IO.FileSystem.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.IO.IsolatedStorage.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.IO.MemoryMappedFiles.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.IO.Pipes.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.IO.UnmanagedMemoryStream.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.IO.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Linq.Expressions.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Linq.Parallel.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Linq.Queryable.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Linq.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Net.Http.Rtc.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Net.NameResolution.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Net.NetworkInformation.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Net.Ping.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Net.Primitives.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Net.Requests.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Net.Security.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Net.Sockets.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Net.WebHeaderCollection.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Net.WebSockets.Client.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Net.WebSockets.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.ObjectModel.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Reflection.Emit.ILGeneration.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Reflection.Emit.Lightweight.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Reflection.Emit.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Reflection.Extensions.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Reflection.Primitives.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Reflection.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Resources.Reader.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Resources.ResourceManager.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Resources.Writer.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Runtime.CompilerServices.VisualC.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Runtime.Extensions.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Runtime.Handles.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Runtime.InteropServices.RuntimeInformation.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Runtime.InteropServices.WindowsRuntime.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Runtime.InteropServices.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Runtime.Numerics.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Runtime.Serialization.Formatters.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Runtime.Serialization.Json.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Runtime.Serialization.Primitives.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Runtime.Serialization.Xml.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Runtime.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Security.Claims.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Security.Cryptography.Algorithms.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Security.Cryptography.Csp.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Security.Cryptography.Encoding.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Security.Cryptography.Primitives.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Security.Cryptography.X509Certificates.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Security.Principal.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Security.SecureString.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.ServiceModel.Duplex.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.ServiceModel.Http.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.ServiceModel.NetTcp.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.ServiceModel.Primitives.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.ServiceModel.Security.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Text.Encoding.Extensions.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Text.Encoding.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Text.RegularExpressions.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Threading.Overlapped.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Threading.Tasks.Parallel.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Threading.Tasks.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Threading.Thread.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Threading.ThreadPool.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Threading.Timer.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Threading.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.ValueTuple.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Xml.ReaderWriter.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Xml.XDocument.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Xml.XPath.XDocument.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Xml.XPath.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Xml.XmlDocument.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/System.Xml.XmlSerializer.dll",
|
||||
"build/.NETFramework/v4.7.2/Facades/netstandard.dll",
|
||||
"build/.NETFramework/v4.7.2/ISymWrapper.dll",
|
||||
"build/.NETFramework/v4.7.2/ISymWrapper.xml",
|
||||
"build/.NETFramework/v4.7.2/Microsoft.Activities.Build.dll",
|
||||
"build/.NETFramework/v4.7.2/Microsoft.Activities.Build.xml",
|
||||
"build/.NETFramework/v4.7.2/Microsoft.Build.Conversion.v4.0.dll",
|
||||
"build/.NETFramework/v4.7.2/Microsoft.Build.Conversion.v4.0.xml",
|
||||
"build/.NETFramework/v4.7.2/Microsoft.Build.Engine.dll",
|
||||
"build/.NETFramework/v4.7.2/Microsoft.Build.Engine.xml",
|
||||
"build/.NETFramework/v4.7.2/Microsoft.Build.Framework.dll",
|
||||
"build/.NETFramework/v4.7.2/Microsoft.Build.Framework.xml",
|
||||
"build/.NETFramework/v4.7.2/Microsoft.Build.Tasks.v4.0.dll",
|
||||
"build/.NETFramework/v4.7.2/Microsoft.Build.Tasks.v4.0.xml",
|
||||
"build/.NETFramework/v4.7.2/Microsoft.Build.Utilities.v4.0.dll",
|
||||
"build/.NETFramework/v4.7.2/Microsoft.Build.Utilities.v4.0.xml",
|
||||
"build/.NETFramework/v4.7.2/Microsoft.Build.dll",
|
||||
"build/.NETFramework/v4.7.2/Microsoft.Build.xml",
|
||||
"build/.NETFramework/v4.7.2/Microsoft.CSharp.dll",
|
||||
"build/.NETFramework/v4.7.2/Microsoft.CSharp.xml",
|
||||
"build/.NETFramework/v4.7.2/Microsoft.JScript.dll",
|
||||
"build/.NETFramework/v4.7.2/Microsoft.JScript.xml",
|
||||
"build/.NETFramework/v4.7.2/Microsoft.VisualBasic.Compatibility.Data.dll",
|
||||
"build/.NETFramework/v4.7.2/Microsoft.VisualBasic.Compatibility.Data.xml",
|
||||
"build/.NETFramework/v4.7.2/Microsoft.VisualBasic.Compatibility.dll",
|
||||
"build/.NETFramework/v4.7.2/Microsoft.VisualBasic.Compatibility.xml",
|
||||
"build/.NETFramework/v4.7.2/Microsoft.VisualBasic.dll",
|
||||
"build/.NETFramework/v4.7.2/Microsoft.VisualBasic.xml",
|
||||
"build/.NETFramework/v4.7.2/Microsoft.VisualC.STLCLR.dll",
|
||||
"build/.NETFramework/v4.7.2/Microsoft.VisualC.STLCLR.xml",
|
||||
"build/.NETFramework/v4.7.2/Microsoft.VisualC.dll",
|
||||
"build/.NETFramework/v4.7.2/Microsoft.VisualC.xml",
|
||||
"build/.NETFramework/v4.7.2/PermissionSets/FullTrust.xml",
|
||||
"build/.NETFramework/v4.7.2/PermissionSets/Internet.xml",
|
||||
"build/.NETFramework/v4.7.2/PermissionSets/LocalIntranet.xml",
|
||||
"build/.NETFramework/v4.7.2/PresentationBuildTasks.dll",
|
||||
"build/.NETFramework/v4.7.2/PresentationBuildTasks.xml",
|
||||
"build/.NETFramework/v4.7.2/PresentationCore.dll",
|
||||
"build/.NETFramework/v4.7.2/PresentationCore.xml",
|
||||
"build/.NETFramework/v4.7.2/PresentationFramework.Aero.dll",
|
||||
"build/.NETFramework/v4.7.2/PresentationFramework.Aero.xml",
|
||||
"build/.NETFramework/v4.7.2/PresentationFramework.Aero2.dll",
|
||||
"build/.NETFramework/v4.7.2/PresentationFramework.Aero2.xml",
|
||||
"build/.NETFramework/v4.7.2/PresentationFramework.AeroLite.dll",
|
||||
"build/.NETFramework/v4.7.2/PresentationFramework.AeroLite.xml",
|
||||
"build/.NETFramework/v4.7.2/PresentationFramework.Classic.dll",
|
||||
"build/.NETFramework/v4.7.2/PresentationFramework.Classic.xml",
|
||||
"build/.NETFramework/v4.7.2/PresentationFramework.Luna.dll",
|
||||
"build/.NETFramework/v4.7.2/PresentationFramework.Luna.xml",
|
||||
"build/.NETFramework/v4.7.2/PresentationFramework.Royale.dll",
|
||||
"build/.NETFramework/v4.7.2/PresentationFramework.Royale.xml",
|
||||
"build/.NETFramework/v4.7.2/PresentationFramework.dll",
|
||||
"build/.NETFramework/v4.7.2/PresentationFramework.xml",
|
||||
"build/.NETFramework/v4.7.2/ReachFramework.dll",
|
||||
"build/.NETFramework/v4.7.2/ReachFramework.xml",
|
||||
"build/.NETFramework/v4.7.2/RedistList/FrameworkList.xml",
|
||||
"build/.NETFramework/v4.7.2/System.Activities.Core.Presentation.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Activities.Core.Presentation.xml",
|
||||
"build/.NETFramework/v4.7.2/System.Activities.DurableInstancing.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Activities.DurableInstancing.xml",
|
||||
"build/.NETFramework/v4.7.2/System.Activities.Presentation.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Activities.Presentation.xml",
|
||||
"build/.NETFramework/v4.7.2/System.Activities.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Activities.xml",
|
||||
"build/.NETFramework/v4.7.2/System.AddIn.Contract.dll",
|
||||
"build/.NETFramework/v4.7.2/System.AddIn.Contract.xml",
|
||||
"build/.NETFramework/v4.7.2/System.AddIn.dll",
|
||||
"build/.NETFramework/v4.7.2/System.AddIn.xml",
|
||||
"build/.NETFramework/v4.7.2/System.ComponentModel.Composition.Registration.dll",
|
||||
"build/.NETFramework/v4.7.2/System.ComponentModel.Composition.Registration.xml",
|
||||
"build/.NETFramework/v4.7.2/System.ComponentModel.Composition.dll",
|
||||
"build/.NETFramework/v4.7.2/System.ComponentModel.Composition.xml",
|
||||
"build/.NETFramework/v4.7.2/System.ComponentModel.DataAnnotations.dll",
|
||||
"build/.NETFramework/v4.7.2/System.ComponentModel.DataAnnotations.xml",
|
||||
"build/.NETFramework/v4.7.2/System.Configuration.Install.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Configuration.Install.xml",
|
||||
"build/.NETFramework/v4.7.2/System.Configuration.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Configuration.xml",
|
||||
"build/.NETFramework/v4.7.2/System.Core.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Core.xml",
|
||||
"build/.NETFramework/v4.7.2/System.Data.DataSetExtensions.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Data.DataSetExtensions.xml",
|
||||
"build/.NETFramework/v4.7.2/System.Data.Entity.Design.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Data.Entity.Design.xml",
|
||||
"build/.NETFramework/v4.7.2/System.Data.Entity.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Data.Entity.xml",
|
||||
"build/.NETFramework/v4.7.2/System.Data.Linq.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Data.Linq.xml",
|
||||
"build/.NETFramework/v4.7.2/System.Data.OracleClient.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Data.OracleClient.xml",
|
||||
"build/.NETFramework/v4.7.2/System.Data.Services.Client.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Data.Services.Client.xml",
|
||||
"build/.NETFramework/v4.7.2/System.Data.Services.Design.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Data.Services.Design.xml",
|
||||
"build/.NETFramework/v4.7.2/System.Data.Services.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Data.Services.xml",
|
||||
"build/.NETFramework/v4.7.2/System.Data.SqlXml.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Data.SqlXml.xml",
|
||||
"build/.NETFramework/v4.7.2/System.Data.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Data.xml",
|
||||
"build/.NETFramework/v4.7.2/System.Deployment.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Deployment.xml",
|
||||
"build/.NETFramework/v4.7.2/System.Design.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Design.xml",
|
||||
"build/.NETFramework/v4.7.2/System.Device.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Device.xml",
|
||||
"build/.NETFramework/v4.7.2/System.Diagnostics.Tracing.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Diagnostics.Tracing.xml",
|
||||
"build/.NETFramework/v4.7.2/System.DirectoryServices.AccountManagement.dll",
|
||||
"build/.NETFramework/v4.7.2/System.DirectoryServices.AccountManagement.xml",
|
||||
"build/.NETFramework/v4.7.2/System.DirectoryServices.Protocols.dll",
|
||||
"build/.NETFramework/v4.7.2/System.DirectoryServices.Protocols.xml",
|
||||
"build/.NETFramework/v4.7.2/System.DirectoryServices.dll",
|
||||
"build/.NETFramework/v4.7.2/System.DirectoryServices.xml",
|
||||
"build/.NETFramework/v4.7.2/System.Drawing.Design.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Drawing.Design.xml",
|
||||
"build/.NETFramework/v4.7.2/System.Drawing.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Drawing.xml",
|
||||
"build/.NETFramework/v4.7.2/System.Dynamic.dll",
|
||||
"build/.NETFramework/v4.7.2/System.EnterpriseServices.Thunk.dll",
|
||||
"build/.NETFramework/v4.7.2/System.EnterpriseServices.Wrapper.dll",
|
||||
"build/.NETFramework/v4.7.2/System.EnterpriseServices.dll",
|
||||
"build/.NETFramework/v4.7.2/System.EnterpriseServices.xml",
|
||||
"build/.NETFramework/v4.7.2/System.IO.Compression.FileSystem.dll",
|
||||
"build/.NETFramework/v4.7.2/System.IO.Compression.FileSystem.xml",
|
||||
"build/.NETFramework/v4.7.2/System.IO.Compression.dll",
|
||||
"build/.NETFramework/v4.7.2/System.IO.Compression.xml",
|
||||
"build/.NETFramework/v4.7.2/System.IO.Log.dll",
|
||||
"build/.NETFramework/v4.7.2/System.IO.Log.xml",
|
||||
"build/.NETFramework/v4.7.2/System.IdentityModel.Selectors.dll",
|
||||
"build/.NETFramework/v4.7.2/System.IdentityModel.Selectors.xml",
|
||||
"build/.NETFramework/v4.7.2/System.IdentityModel.Services.dll",
|
||||
"build/.NETFramework/v4.7.2/System.IdentityModel.Services.xml",
|
||||
"build/.NETFramework/v4.7.2/System.IdentityModel.dll",
|
||||
"build/.NETFramework/v4.7.2/System.IdentityModel.xml",
|
||||
"build/.NETFramework/v4.7.2/System.Linq.xml",
|
||||
"build/.NETFramework/v4.7.2/System.Management.Instrumentation.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Management.Instrumentation.xml",
|
||||
"build/.NETFramework/v4.7.2/System.Management.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Management.xml",
|
||||
"build/.NETFramework/v4.7.2/System.Messaging.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Messaging.xml",
|
||||
"build/.NETFramework/v4.7.2/System.Net.Http.WebRequest.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Net.Http.WebRequest.xml",
|
||||
"build/.NETFramework/v4.7.2/System.Net.Http.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Net.Http.xml",
|
||||
"build/.NETFramework/v4.7.2/System.Net.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Net.xml",
|
||||
"build/.NETFramework/v4.7.2/System.Numerics.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Numerics.xml",
|
||||
"build/.NETFramework/v4.7.2/System.Printing.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Printing.xml",
|
||||
"build/.NETFramework/v4.7.2/System.Reflection.Context.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Reflection.Context.xml",
|
||||
"build/.NETFramework/v4.7.2/System.Runtime.Caching.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Runtime.Caching.xml",
|
||||
"build/.NETFramework/v4.7.2/System.Runtime.DurableInstancing.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Runtime.DurableInstancing.xml",
|
||||
"build/.NETFramework/v4.7.2/System.Runtime.Remoting.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Runtime.Remoting.xml",
|
||||
"build/.NETFramework/v4.7.2/System.Runtime.Serialization.Formatters.Soap.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Runtime.Serialization.Formatters.Soap.xml",
|
||||
"build/.NETFramework/v4.7.2/System.Runtime.Serialization.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Runtime.Serialization.xml",
|
||||
"build/.NETFramework/v4.7.2/System.Security.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Security.xml",
|
||||
"build/.NETFramework/v4.7.2/System.ServiceModel.Activation.dll",
|
||||
"build/.NETFramework/v4.7.2/System.ServiceModel.Activation.xml",
|
||||
"build/.NETFramework/v4.7.2/System.ServiceModel.Activities.dll",
|
||||
"build/.NETFramework/v4.7.2/System.ServiceModel.Activities.xml",
|
||||
"build/.NETFramework/v4.7.2/System.ServiceModel.Channels.dll",
|
||||
"build/.NETFramework/v4.7.2/System.ServiceModel.Channels.xml",
|
||||
"build/.NETFramework/v4.7.2/System.ServiceModel.Discovery.dll",
|
||||
"build/.NETFramework/v4.7.2/System.ServiceModel.Discovery.xml",
|
||||
"build/.NETFramework/v4.7.2/System.ServiceModel.Routing.dll",
|
||||
"build/.NETFramework/v4.7.2/System.ServiceModel.Routing.xml",
|
||||
"build/.NETFramework/v4.7.2/System.ServiceModel.Web.dll",
|
||||
"build/.NETFramework/v4.7.2/System.ServiceModel.Web.xml",
|
||||
"build/.NETFramework/v4.7.2/System.ServiceModel.dll",
|
||||
"build/.NETFramework/v4.7.2/System.ServiceModel.xml",
|
||||
"build/.NETFramework/v4.7.2/System.ServiceProcess.dll",
|
||||
"build/.NETFramework/v4.7.2/System.ServiceProcess.xml",
|
||||
"build/.NETFramework/v4.7.2/System.Speech.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Speech.xml",
|
||||
"build/.NETFramework/v4.7.2/System.Threading.Tasks.Dataflow.xml",
|
||||
"build/.NETFramework/v4.7.2/System.Transactions.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Transactions.xml",
|
||||
"build/.NETFramework/v4.7.2/System.Web.Abstractions.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Web.ApplicationServices.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Web.ApplicationServices.xml",
|
||||
"build/.NETFramework/v4.7.2/System.Web.DataVisualization.Design.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Web.DataVisualization.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Web.DataVisualization.xml",
|
||||
"build/.NETFramework/v4.7.2/System.Web.DynamicData.Design.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Web.DynamicData.Design.xml",
|
||||
"build/.NETFramework/v4.7.2/System.Web.DynamicData.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Web.DynamicData.xml",
|
||||
"build/.NETFramework/v4.7.2/System.Web.Entity.Design.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Web.Entity.Design.xml",
|
||||
"build/.NETFramework/v4.7.2/System.Web.Entity.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Web.Entity.xml",
|
||||
"build/.NETFramework/v4.7.2/System.Web.Extensions.Design.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Web.Extensions.Design.xml",
|
||||
"build/.NETFramework/v4.7.2/System.Web.Extensions.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Web.Extensions.xml",
|
||||
"build/.NETFramework/v4.7.2/System.Web.Mobile.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Web.Mobile.xml",
|
||||
"build/.NETFramework/v4.7.2/System.Web.RegularExpressions.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Web.RegularExpressions.xml",
|
||||
"build/.NETFramework/v4.7.2/System.Web.Routing.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Web.Services.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Web.Services.xml",
|
||||
"build/.NETFramework/v4.7.2/System.Web.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Web.xml",
|
||||
"build/.NETFramework/v4.7.2/System.Windows.Controls.Ribbon.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Windows.Controls.Ribbon.xml",
|
||||
"build/.NETFramework/v4.7.2/System.Windows.Forms.DataVisualization.Design.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Windows.Forms.DataVisualization.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Windows.Forms.DataVisualization.xml",
|
||||
"build/.NETFramework/v4.7.2/System.Windows.Forms.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Windows.Forms.xml",
|
||||
"build/.NETFramework/v4.7.2/System.Windows.Input.Manipulations.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Windows.Input.Manipulations.xml",
|
||||
"build/.NETFramework/v4.7.2/System.Windows.Presentation.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Windows.Presentation.xml",
|
||||
"build/.NETFramework/v4.7.2/System.Windows.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Workflow.Activities.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Workflow.Activities.xml",
|
||||
"build/.NETFramework/v4.7.2/System.Workflow.ComponentModel.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Workflow.ComponentModel.xml",
|
||||
"build/.NETFramework/v4.7.2/System.Workflow.Runtime.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Workflow.Runtime.xml",
|
||||
"build/.NETFramework/v4.7.2/System.WorkflowServices.dll",
|
||||
"build/.NETFramework/v4.7.2/System.WorkflowServices.xml",
|
||||
"build/.NETFramework/v4.7.2/System.Xaml.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Xaml.xml",
|
||||
"build/.NETFramework/v4.7.2/System.Xml.Linq.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Xml.Linq.xml",
|
||||
"build/.NETFramework/v4.7.2/System.Xml.Serialization.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Xml.dll",
|
||||
"build/.NETFramework/v4.7.2/System.Xml.xml",
|
||||
"build/.NETFramework/v4.7.2/System.dll",
|
||||
"build/.NETFramework/v4.7.2/System.xml",
|
||||
"build/.NETFramework/v4.7.2/UIAutomationClient.dll",
|
||||
"build/.NETFramework/v4.7.2/UIAutomationClient.xml",
|
||||
"build/.NETFramework/v4.7.2/UIAutomationClientsideProviders.dll",
|
||||
"build/.NETFramework/v4.7.2/UIAutomationClientsideProviders.xml",
|
||||
"build/.NETFramework/v4.7.2/UIAutomationProvider.dll",
|
||||
"build/.NETFramework/v4.7.2/UIAutomationProvider.xml",
|
||||
"build/.NETFramework/v4.7.2/UIAutomationTypes.dll",
|
||||
"build/.NETFramework/v4.7.2/UIAutomationTypes.xml",
|
||||
"build/.NETFramework/v4.7.2/WindowsBase.dll",
|
||||
"build/.NETFramework/v4.7.2/WindowsBase.xml",
|
||||
"build/.NETFramework/v4.7.2/WindowsFormsIntegration.dll",
|
||||
"build/.NETFramework/v4.7.2/WindowsFormsIntegration.xml",
|
||||
"build/.NETFramework/v4.7.2/XamlBuildTask.dll",
|
||||
"build/.NETFramework/v4.7.2/XamlBuildTask.xml",
|
||||
"build/.NETFramework/v4.7.2/mscorlib.dll",
|
||||
"build/.NETFramework/v4.7.2/mscorlib.xml",
|
||||
"build/.NETFramework/v4.7.2/namespaces.xml",
|
||||
"build/.NETFramework/v4.7.2/sysglobl.dll",
|
||||
"build/.NETFramework/v4.7.2/sysglobl.xml",
|
||||
"build/Microsoft.NETFramework.ReferenceAssemblies.net472.targets",
|
||||
"microsoft.netframework.referenceassemblies.net472.1.0.3.nupkg.sha512",
|
||||
"microsoft.netframework.referenceassemblies.net472.nuspec"
|
||||
]
|
||||
}
|
||||
},
|
||||
"projectFileDependencyGroups": {
|
||||
".NETFramework,Version=v4.7.2": [
|
||||
"Microsoft.NETFramework.ReferenceAssemblies >= 1.0.3"
|
||||
]
|
||||
},
|
||||
"packageFolders": {
|
||||
"C:\\Users\\gruessing\\.nuget\\packages\\": {}
|
||||
},
|
||||
"project": {
|
||||
"version": "1.0.0",
|
||||
"restore": {
|
||||
"projectUniqueName": "C:\\Users\\gruessing\\OneDrive - Cereda Systems GmbH\\Desktop\\IT Tool\\domain-join-tool\\DomainJoinTool.csproj",
|
||||
"projectName": "DomainJoinTool",
|
||||
"projectPath": "C:\\Users\\gruessing\\OneDrive - Cereda Systems GmbH\\Desktop\\IT Tool\\domain-join-tool\\DomainJoinTool.csproj",
|
||||
"packagesPath": "C:\\Users\\gruessing\\.nuget\\packages\\",
|
||||
"outputPath": "C:\\Users\\gruessing\\OneDrive - Cereda Systems GmbH\\Desktop\\IT Tool\\domain-join-tool\\obj\\",
|
||||
"projectStyle": "PackageReference",
|
||||
"configFilePaths": [
|
||||
"C:\\Users\\gruessing\\AppData\\Roaming\\NuGet\\NuGet.Config"
|
||||
],
|
||||
"originalTargetFrameworks": [
|
||||
"net472"
|
||||
],
|
||||
"sources": {
|
||||
"https://api.nuget.org/v3/index.json": {}
|
||||
},
|
||||
"frameworks": {
|
||||
"net472": {
|
||||
"targetAlias": "net472",
|
||||
"projectReferences": {}
|
||||
}
|
||||
},
|
||||
"warningProperties": {
|
||||
"warnAsError": [
|
||||
"NU1605"
|
||||
]
|
||||
},
|
||||
"restoreAuditProperties": {
|
||||
"enableAudit": "true",
|
||||
"auditLevel": "low",
|
||||
"auditMode": "direct"
|
||||
}
|
||||
},
|
||||
"frameworks": {
|
||||
"net472": {
|
||||
"targetAlias": "net472",
|
||||
"dependencies": {
|
||||
"Microsoft.NETFramework.ReferenceAssemblies": {
|
||||
"suppressParent": "All",
|
||||
"target": "Package",
|
||||
"version": "[1.0.3, )",
|
||||
"autoReferenced": true
|
||||
}
|
||||
},
|
||||
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\8.0.421\\RuntimeIdentifierGraph.json"
|
||||
}
|
||||
},
|
||||
"runtimes": {
|
||||
"win7-x64": {
|
||||
"#import": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"version": 2,
|
||||
"dgSpecHash": "nUWqqbOYUIM=",
|
||||
"success": true,
|
||||
"projectFilePath": "C:\\Users\\gruessing\\OneDrive - Cereda Systems GmbH\\Desktop\\IT Tool\\domain-join-tool\\DomainJoinTool.csproj",
|
||||
"expectedPackageFiles": [
|
||||
"C:\\Users\\gruessing\\.nuget\\packages\\microsoft.netframework.referenceassemblies\\1.0.3\\microsoft.netframework.referenceassemblies.1.0.3.nupkg.sha512",
|
||||
"C:\\Users\\gruessing\\.nuget\\packages\\microsoft.netframework.referenceassemblies.net472\\1.0.3\\microsoft.netframework.referenceassemblies.net472.1.0.3.nupkg.sha512"
|
||||
],
|
||||
"logs": []
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { useAuth } from '../context/AuthContext';
|
||||
|
||||
const ANN_TYPES = { maintenance: { icon: '🔧', label: 'Wartung', color: '#f59e0b' }, warning: { icon: '⚠️', label: 'Warnung', color: '#ef4444' }, info: { icon: 'ℹ️', label: 'Info', color: '#6366f1' } };
|
||||
|
||||
const LATEST_AGENT_VERSION = '2.7.0';
|
||||
const LATEST_AGENT_VERSION = '2.9.0';
|
||||
const SEVERITY_LABELS = { critical: 'Kritisch', important: 'Wichtig', moderate: 'Moderat', low: 'Niedrig', all: 'Alle' };
|
||||
const SEVERITY_COLORS = { critical: '#EF4444', important: '#F59E0B', moderate: '#3B82F6', low: '#6B7280', all: '#0D9488' };
|
||||
const COMMAND_LABELS = { check_updates: '🔍 Update-Scan', install_updates: '⬇️ Installation', reboot: '🔄 Neustart', upgrade_win11: '🪟 Win 11 Upgrade', update_agent: '⬆️ Agent-Update' };
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
BINARY := nexus-scanner
|
||||
CMD := ./cmd/nexus-scanner
|
||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||
LDFLAGS := -ldflags "-s -w -X main.version=$(VERSION)"
|
||||
|
||||
.PHONY: build run test fmt vet clean install
|
||||
|
||||
## build: compile for the local OS/arch
|
||||
build:
|
||||
go build $(LDFLAGS) -o $(BINARY) $(CMD)
|
||||
|
||||
## build-linux: cross-compile for Linux amd64 + arm64
|
||||
build-linux:
|
||||
GOOS=linux GOARCH=amd64 go build $(LDFLAGS) -o $(BINARY)-linux-amd64 $(CMD)
|
||||
GOOS=linux GOARCH=arm64 go build $(LDFLAGS) -o $(BINARY)-linux-arm64 $(CMD)
|
||||
|
||||
## run: build and run with the example config (local testing only)
|
||||
run: build
|
||||
./$(BINARY) config.example.yaml
|
||||
|
||||
## test: run all tests
|
||||
test:
|
||||
go test -v -race ./...
|
||||
|
||||
## fmt: format all Go source files
|
||||
fmt:
|
||||
gofmt -w ./...
|
||||
|
||||
## vet: run go vet
|
||||
vet:
|
||||
go vet ./...
|
||||
|
||||
## tidy: update go.sum and remove unused dependencies
|
||||
tidy:
|
||||
go mod tidy
|
||||
|
||||
## clean: remove built binaries
|
||||
clean:
|
||||
rm -f $(BINARY) $(BINARY)-linux-amd64 $(BINARY)-linux-arm64
|
||||
|
||||
## install: deploy to a scanner VM (set SCANNER_HOST env var)
|
||||
## Usage: SCANNER_HOST=root@192.168.0.x make install
|
||||
install: build-linux
|
||||
scp $(BINARY)-linux-amd64 $(SCANNER_HOST):/usr/local/bin/$(BINARY)
|
||||
ssh $(SCANNER_HOST) "systemctl restart $(BINARY)"
|
||||
|
||||
## help: print this help
|
||||
help:
|
||||
@grep -E '^## ' Makefile | sed 's/## / /'
|
||||
@@ -1,156 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/cereda-systems/nexus-scanner/internal/config"
|
||||
"github.com/cereda-systems/nexus-scanner/internal/db"
|
||||
"github.com/cereda-systems/nexus-scanner/internal/module"
|
||||
arpmod "github.com/cereda-systems/nexus-scanner/internal/modules/arp"
|
||||
admod "github.com/cereda-systems/nexus-scanner/internal/modules/adsync"
|
||||
dnsmod "github.com/cereda-systems/nexus-scanner/internal/modules/dnsreverse"
|
||||
vendormod "github.com/cereda-systems/nexus-scanner/internal/modules/macvendor"
|
||||
reportermod "github.com/cereda-systems/nexus-scanner/internal/modules/nexusreporter"
|
||||
portscanmod "github.com/cereda-systems/nexus-scanner/internal/modules/portscan"
|
||||
sitemon "github.com/cereda-systems/nexus-scanner/internal/modules/sitemon"
|
||||
snmpmod "github.com/cereda-systems/nexus-scanner/internal/modules/snmpmod"
|
||||
sysarpmod "github.com/cereda-systems/nexus-scanner/internal/modules/sysarp"
|
||||
"github.com/cereda-systems/nexus-scanner/internal/scheduler"
|
||||
"github.com/cereda-systems/nexus-scanner/internal/web"
|
||||
)
|
||||
|
||||
var version = "dev"
|
||||
|
||||
func main() {
|
||||
cfgPath := "/etc/nexus-scanner/config.yaml"
|
||||
if len(os.Args) > 1 {
|
||||
cfgPath = os.Args[1]
|
||||
}
|
||||
|
||||
cfg, err := config.Load(cfgPath)
|
||||
if err != nil {
|
||||
slog.Error("failed to load config", "path", cfgPath, "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Log buffer captures entries for the /logs web page.
|
||||
logBuf := web.NewLogBuffer(500)
|
||||
|
||||
level := slog.LevelInfo
|
||||
if cfg.LogLevel == "debug" {
|
||||
level = slog.LevelDebug
|
||||
}
|
||||
inner := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: level})
|
||||
handler := web.NewSlogHandler(inner, logBuf)
|
||||
slog.SetDefault(slog.New(handler))
|
||||
|
||||
slog.Info("nexus-scanner starting", "version", version, "site", cfg.Site)
|
||||
|
||||
store, err := db.Open(cfg.DB.Path)
|
||||
if err != nil {
|
||||
slog.Error("failed to open database", "path", cfg.DB.Path, "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
registry := module.NewRegistry()
|
||||
|
||||
// sys_arp: reads the OS ARP cache — works on Windows, Linux and macOS
|
||||
// without raw-socket privileges. Always enabled.
|
||||
registry.Register(sysarpmod.New(cfg.Site, store))
|
||||
slog.Info("module registered", "module", "sys_arp")
|
||||
|
||||
// dns_reverse: enriches known hosts with reverse-DNS hostnames.
|
||||
registry.Register(dnsmod.New(cfg.Site, store))
|
||||
slog.Info("module registered", "module", "dns_reverse")
|
||||
|
||||
// mac_vendor: assigns OUI-based vendor names to known hosts (runs once at startup).
|
||||
registry.Register(vendormod.New(cfg.Site, store))
|
||||
slog.Info("module registered", "module", "mac_vendor")
|
||||
|
||||
// ad_sync: queries Active Directory for all computer objects.
|
||||
if cfg.Modules.ADSync.Enabled {
|
||||
registry.Register(admod.New(cfg.Site, cfg.Modules.ADSync, store))
|
||||
slog.Info("module registered", "module", "ad_sync",
|
||||
"server", cfg.Modules.ADSync.Server,
|
||||
"search_base", cfg.Modules.ADSync.SearchBase,
|
||||
)
|
||||
}
|
||||
|
||||
// site_monitoring: availability checks (ping, http, tcp) — always enabled,
|
||||
// checks are managed via the web UI.
|
||||
cfg.Modules.SiteMonitoring.Enabled = true
|
||||
registry.Register(sitemon.New(cfg.Site, cfg.Modules.SiteMonitoring, cfg.Alert, *cfg, store))
|
||||
slog.Info("module registered", "module", "site_monitoring",
|
||||
"interval", cfg.Modules.SiteMonitoring.Interval)
|
||||
|
||||
// port_scan: TCP port scanner for all online hosts.
|
||||
cfg.Modules.PortScan.Enabled = true
|
||||
registry.Register(portscanmod.New(cfg.Site, cfg.Modules.PortScan, store))
|
||||
slog.Info("module registered", "module", "port_scan",
|
||||
"interval", cfg.Modules.PortScan.Interval)
|
||||
|
||||
// snmp: polls SNMP targets for device info (managed via web UI).
|
||||
cfg.Modules.SNMP.Enabled = true
|
||||
registry.Register(snmpmod.New(cfg.Site, cfg.Modules.SNMP, store))
|
||||
slog.Info("module registered", "module", "snmp",
|
||||
"interval", cfg.Modules.SNMP.Interval)
|
||||
|
||||
// nexus_reporter: pushes changed assets to IT Nexus API.
|
||||
if cfg.Nexus.URL != "" && cfg.Nexus.APIKey != "" {
|
||||
cfg.Modules.NexusReporter.Enabled = true
|
||||
registry.Register(reportermod.New(cfg.Site, cfg.Modules.NexusReporter, *cfg, store))
|
||||
slog.Info("module registered", "module", "nexus_reporter")
|
||||
}
|
||||
|
||||
// arp_discovery: full ARP sweep via raw sockets (Linux only).
|
||||
if cfg.Modules.ARPDiscovery.Enabled {
|
||||
registry.Register(arpmod.New(cfg.Site, cfg.Modules.ARPDiscovery, store))
|
||||
slog.Info("module registered", "module", "arp_discovery",
|
||||
"interval", cfg.Modules.ARPDiscovery.Interval,
|
||||
"subnets", cfg.Modules.ARPDiscovery.Subnets,
|
||||
)
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
// Offline-Sweeper: marks hosts as offline when not seen for OfflineAfter duration.
|
||||
go func() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
n, err := store.MarkStaleHostsOffline(cfg.OfflineAfter)
|
||||
if err != nil {
|
||||
slog.Error("offline sweep", "err", err)
|
||||
} else if n > 0 {
|
||||
slog.Info("hosts marked offline", "count", n, "threshold", cfg.OfflineAfter)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
sched := scheduler.New(registry)
|
||||
go sched.Run(ctx)
|
||||
|
||||
srv := web.NewServer(cfgPath, cfg, store, registry, logBuf)
|
||||
go func() {
|
||||
if err := srv.ListenAndServe(ctx); err != nil {
|
||||
slog.Error("web server stopped", "err", err)
|
||||
stop()
|
||||
}
|
||||
}()
|
||||
|
||||
slog.Info("nexus-scanner ready", "addr", cfg.Web.Addr)
|
||||
<-ctx.Done()
|
||||
slog.Info("nexus-scanner shutting down")
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
site: LUD
|
||||
log_level: debug
|
||||
setup_complete: true
|
||||
db:
|
||||
path: C:/temp/nexus-scanner/scanner.db
|
||||
web:
|
||||
addr: :8090
|
||||
token: demo1234
|
||||
nexus:
|
||||
url: https://it-nexus.cereda-systems.de
|
||||
api_key: nsx-aa0193d719c2be765441e1dd24bf7480
|
||||
offline_after: 15m0s
|
||||
alert:
|
||||
smtp:
|
||||
enabled: false
|
||||
host: ""
|
||||
port: 587
|
||||
username: ""
|
||||
password: ""
|
||||
from: ""
|
||||
to: ""
|
||||
nexus_enabled: true
|
||||
modules:
|
||||
arp_discovery:
|
||||
enabled: false
|
||||
interval: 5m0s
|
||||
subnets:
|
||||
- 192.168.0.0/24
|
||||
interface: eth0
|
||||
ad_sync:
|
||||
enabled: true
|
||||
interval: 30m0s
|
||||
server: 192.168.0.12
|
||||
port: 389
|
||||
bind_dn: svc-scanner@winkel.local
|
||||
bind_password: 6ApvVf6X9hvHBxYUqQ3y
|
||||
search_base: DC=winkel,DC=local
|
||||
tls: false
|
||||
site_monitoring:
|
||||
enabled: true
|
||||
interval: 1m0s
|
||||
port_scan:
|
||||
enabled: true
|
||||
interval: 15m0s
|
||||
ports: []
|
||||
timeout: 500ms
|
||||
snmp:
|
||||
enabled: true
|
||||
interval: 5m0s
|
||||
community: public
|
||||
nexus_reporter:
|
||||
enabled: true
|
||||
interval: 10m0s
|
||||
@@ -1,35 +0,0 @@
|
||||
# Nexus Scanner — example configuration
|
||||
# Copy to /etc/nexus-scanner/config.yaml and adjust.
|
||||
|
||||
site: "LUD" # LUD = Lüdenscheid | BAR = Barleben
|
||||
log_level: "info" # debug | info | warn | error
|
||||
|
||||
db:
|
||||
path: "/var/lib/nexus-scanner/scanner.db"
|
||||
|
||||
web:
|
||||
addr: ":8080"
|
||||
token: "change-me-generate-with-openssl-rand-hex-32"
|
||||
|
||||
nexus:
|
||||
url: "https://it-nexus.cereda-systems.de"
|
||||
api_key: "" # will be filled once IT Nexus wires up the scanner endpoint
|
||||
|
||||
modules:
|
||||
ad_sync:
|
||||
enabled: true
|
||||
interval: 30m
|
||||
server: "192.168.0.1" # IP des Domain Controllers
|
||||
port: 389 # 389 = LDAP, 636 = LDAPS
|
||||
bind_dn: "svc-scanner@winkel.local"
|
||||
bind_password: "" # Passwort des Service-Accounts
|
||||
search_base: "DC=winkel,DC=local"
|
||||
tls: false
|
||||
|
||||
arp_discovery:
|
||||
enabled: true
|
||||
interval: 5m # how often to scan (Go duration: 30s, 5m, 1h …)
|
||||
interface: "eth0" # network interface to send ARP requests on
|
||||
subnets:
|
||||
- "192.168.0.0/24" # add more subnets as needed
|
||||
# - "10.0.0.0/24"
|
||||
@@ -1,22 +0,0 @@
|
||||
# Minimale Startkonfiguration — wird nach dem Setup-Wizard überschrieben.
|
||||
site: "LUD"
|
||||
log_level: "info"
|
||||
setup_complete: false
|
||||
|
||||
db:
|
||||
path: "/var/lib/nexus-scanner/scanner.db"
|
||||
|
||||
web:
|
||||
addr: ":8080"
|
||||
token: "" # wird im Setup-Wizard gesetzt
|
||||
|
||||
nexus:
|
||||
url: ""
|
||||
api_key: ""
|
||||
|
||||
modules:
|
||||
arp_discovery:
|
||||
enabled: false # wird im Setup-Wizard aktiviert
|
||||
interval: 5m
|
||||
interface: "eth0"
|
||||
subnets: []
|
||||
@@ -1,62 +0,0 @@
|
||||
site: "BAR"
|
||||
log_level: "info"
|
||||
setup_complete: false
|
||||
|
||||
db:
|
||||
path: "/var/lib/nexus-scanner/scanner.db"
|
||||
|
||||
web:
|
||||
addr: ":8090"
|
||||
token: "" # wird beim Setup-Wizard gesetzt
|
||||
|
||||
nexus:
|
||||
url: "https://it-nexus.cereda-systems.de"
|
||||
api_key: "nsx-aa0193d719c2be765441e1dd24bf7480"
|
||||
|
||||
offline_after: 15m
|
||||
|
||||
alert:
|
||||
nexus_enabled: true
|
||||
smtp:
|
||||
enabled: false
|
||||
host: ""
|
||||
port: 587
|
||||
username: ""
|
||||
password: ""
|
||||
from: ""
|
||||
to: ""
|
||||
|
||||
modules:
|
||||
arp_discovery:
|
||||
enabled: true
|
||||
interval: 5m
|
||||
subnets:
|
||||
- "192.168.10.0/24" # Barleben-Netz anpassen!
|
||||
interface: "eth0"
|
||||
|
||||
ad_sync:
|
||||
enabled: false # DC-Daten für BAR eintragen und auf true setzen
|
||||
interval: 30m
|
||||
server: ""
|
||||
port: 389
|
||||
bind_dn: "svc-scanner@winkel.local"
|
||||
bind_password: ""
|
||||
search_base: "DC=winkel,DC=local"
|
||||
tls: false
|
||||
|
||||
site_monitoring:
|
||||
enabled: true
|
||||
interval: 1m
|
||||
|
||||
port_scan:
|
||||
enabled: true
|
||||
interval: 15m
|
||||
|
||||
snmp:
|
||||
enabled: true
|
||||
interval: 5m
|
||||
community: "public"
|
||||
|
||||
nexus_reporter:
|
||||
enabled: true
|
||||
interval: 10m
|
||||
@@ -1,62 +0,0 @@
|
||||
site: "LUD"
|
||||
log_level: "info"
|
||||
setup_complete: false
|
||||
|
||||
db:
|
||||
path: "/var/lib/nexus-scanner/scanner.db"
|
||||
|
||||
web:
|
||||
addr: ":8090"
|
||||
token: "" # wird beim Setup-Wizard gesetzt
|
||||
|
||||
nexus:
|
||||
url: "https://it-nexus.cereda-systems.de"
|
||||
api_key: "nsx-aa0193d719c2be765441e1dd24bf7480"
|
||||
|
||||
offline_after: 15m
|
||||
|
||||
alert:
|
||||
nexus_enabled: true
|
||||
smtp:
|
||||
enabled: false
|
||||
host: ""
|
||||
port: 587
|
||||
username: ""
|
||||
password: ""
|
||||
from: ""
|
||||
to: ""
|
||||
|
||||
modules:
|
||||
arp_discovery:
|
||||
enabled: true
|
||||
interval: 5m
|
||||
subnets:
|
||||
- "192.168.0.0/24"
|
||||
interface: "eth0"
|
||||
|
||||
ad_sync:
|
||||
enabled: true
|
||||
interval: 30m
|
||||
server: "192.168.0.12"
|
||||
port: 389
|
||||
bind_dn: "svc-scanner@winkel.local"
|
||||
bind_password: "6ApvVf6X9hvHBxYUqQ3y"
|
||||
search_base: "DC=winkel,DC=local"
|
||||
tls: false
|
||||
|
||||
site_monitoring:
|
||||
enabled: true
|
||||
interval: 1m
|
||||
|
||||
port_scan:
|
||||
enabled: true
|
||||
interval: 15m
|
||||
|
||||
snmp:
|
||||
enabled: true
|
||||
interval: 5m
|
||||
community: "public"
|
||||
|
||||
nexus_reporter:
|
||||
enabled: true
|
||||
interval: 10m
|
||||
@@ -1,133 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Nexus Scanner — Installations-Skript
|
||||
# Läuft auf Debian 12 / Ubuntu 22.04+
|
||||
# Aufruf: sudo bash install.sh [LUD|BAR]
|
||||
set -e
|
||||
|
||||
SITE="${1:-LUD}"
|
||||
BINARY_URL="https://it-nexus.cereda-systems.de/downloads/nexus-scanner-linux-amd64"
|
||||
BINARY="/usr/local/bin/nexus-scanner"
|
||||
CONFIG_DIR="/etc/nexus-scanner"
|
||||
DATA_DIR="/var/lib/nexus-scanner"
|
||||
SERVICE_FILE="/etc/systemd/system/nexus-scanner.service"
|
||||
USER="nexus-scanner"
|
||||
|
||||
echo "=============================="
|
||||
echo " Nexus Scanner Installation"
|
||||
echo " Standort: $SITE"
|
||||
echo "=============================="
|
||||
|
||||
# Abhängigkeiten
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq curl iputils-ping nmap snmp 2>/dev/null || true
|
||||
|
||||
# Benutzer anlegen
|
||||
if ! id "$USER" &>/dev/null; then
|
||||
useradd --system --no-create-home --shell /usr/sbin/nologin "$USER"
|
||||
echo "Benutzer $USER angelegt."
|
||||
fi
|
||||
|
||||
# Verzeichnisse
|
||||
mkdir -p "$CONFIG_DIR" "$DATA_DIR"
|
||||
chown "$USER:$USER" "$DATA_DIR"
|
||||
|
||||
# Binary installieren (aus deploy/-Verzeichnis oder per Download)
|
||||
if [ -f "./nexus-scanner-linux-amd64" ]; then
|
||||
cp ./nexus-scanner-linux-amd64 "$BINARY"
|
||||
echo "Binary aus lokalem Verzeichnis installiert."
|
||||
else
|
||||
echo "Binary wird heruntergeladen..."
|
||||
curl -fsSL "$BINARY_URL" -o "$BINARY"
|
||||
fi
|
||||
chmod 755 "$BINARY"
|
||||
|
||||
# Config installieren
|
||||
if [ ! -f "$CONFIG_DIR/config.yaml" ]; then
|
||||
if [ -f "./config.${SITE,,}.yaml" ]; then
|
||||
cp "./config.${SITE,,}.yaml" "$CONFIG_DIR/config.yaml"
|
||||
else
|
||||
# Minimal-Config erzeugen
|
||||
cat > "$CONFIG_DIR/config.yaml" << EOF
|
||||
site: "$SITE"
|
||||
log_level: "info"
|
||||
setup_complete: false
|
||||
db:
|
||||
path: "$DATA_DIR/scanner.db"
|
||||
web:
|
||||
addr: ":8090"
|
||||
token: ""
|
||||
nexus:
|
||||
url: "https://it-nexus.cereda-systems.de"
|
||||
api_key: "nsx-aa0193d719c2be765441e1dd24bf7480"
|
||||
offline_after: 15m
|
||||
modules:
|
||||
arp_discovery:
|
||||
enabled: true
|
||||
interval: 5m
|
||||
subnets: ["192.168.0.0/24"]
|
||||
interface: "eth0"
|
||||
site_monitoring:
|
||||
enabled: true
|
||||
interval: 1m
|
||||
port_scan:
|
||||
enabled: true
|
||||
interval: 15m
|
||||
snmp:
|
||||
enabled: true
|
||||
interval: 5m
|
||||
community: "public"
|
||||
nexus_reporter:
|
||||
enabled: true
|
||||
interval: 10m
|
||||
EOF
|
||||
fi
|
||||
echo "Config nach $CONFIG_DIR/config.yaml installiert."
|
||||
fi
|
||||
chown "$USER":"$USER" "$CONFIG_DIR/config.yaml"
|
||||
chmod 640 "$CONFIG_DIR/config.yaml"
|
||||
|
||||
# systemd Service installieren
|
||||
cat > "$SERVICE_FILE" << 'EOF'
|
||||
[Unit]
|
||||
Description=Nexus Scanner — Netzwerk-Discovery Agent
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=nexus-scanner
|
||||
Group=nexus-scanner
|
||||
ExecStart=/usr/local/bin/nexus-scanner /etc/nexus-scanner/config.yaml
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=nexus-scanner
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
ReadWritePaths=/var/lib/nexus-scanner /etc/nexus-scanner
|
||||
PrivateTmp=yes
|
||||
AmbientCapabilities=CAP_NET_RAW CAP_NET_ADMIN
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable nexus-scanner
|
||||
systemctl restart nexus-scanner
|
||||
|
||||
IP=$(hostname -I | awk '{print $1}')
|
||||
echo ""
|
||||
echo "=============================="
|
||||
echo " Installation abgeschlossen!"
|
||||
echo "=============================="
|
||||
echo ""
|
||||
echo " Web-UI: http://$IP:8090"
|
||||
echo " Status: systemctl status nexus-scanner"
|
||||
echo " Logs: journalctl -u nexus-scanner -f"
|
||||
echo ""
|
||||
echo " Öffne http://$IP:8090 im Browser"
|
||||
echo " um den Setup-Wizard abzuschließen."
|
||||
echo ""
|
||||
@@ -1,27 +0,0 @@
|
||||
[Unit]
|
||||
Description=Nexus Scanner — Netzwerk-Discovery Agent
|
||||
Documentation=https://it-nexus.cereda-systems.de
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=nexus-scanner
|
||||
Group=nexus-scanner
|
||||
ExecStart=/usr/local/bin/nexus-scanner /etc/nexus-scanner/config.yaml
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=nexus-scanner
|
||||
|
||||
# Security hardening
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
ReadWritePaths=/var/lib/nexus-scanner
|
||||
PrivateTmp=yes
|
||||
CapabilityBoundingSet=CAP_NET_RAW CAP_NET_ADMIN
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,36 +0,0 @@
|
||||
module github.com/cereda-systems/nexus-scanner
|
||||
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/mdlayher/arp v0.0.0-20220512170110-6706a2966875
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.29.10
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Azure/go-ntlmssp v0.1.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
|
||||
github.com/go-ldap/ldap/v3 v3.4.13 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gosnmp/gosnmp v1.43.2 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/josharian/native v1.0.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mdlayher/ethernet v0.0.0-20220221185849-529eae5b6118 // indirect
|
||||
github.com/mdlayher/packet v1.0.0 // indirect
|
||||
github.com/mdlayher/socket v0.2.1 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/net v0.50.0 // indirect
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
|
||||
modernc.org/libc v1.49.3 // indirect
|
||||
modernc.org/mathutil v1.6.0 // indirect
|
||||
modernc.org/memory v1.8.0 // indirect
|
||||
modernc.org/strutil v1.2.0 // indirect
|
||||
modernc.org/token v1.1.0 // indirect
|
||||
)
|
||||
@@ -1,92 +0,0 @@
|
||||
github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
|
||||
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||
github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ=
|
||||
github.com/go-ldap/ldap/v3 v3.4.13/go.mod h1:LxsGZV6vbaK0sIvYfsv47rfh4ca0JXokCoKjZxsszv0=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o=
|
||||
github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gosnmp/gosnmp v1.43.2 h1:F9loz6uMCNtIQj0RNO5wz/mZ+FZt2WyNKJYOvw+Zosw=
|
||||
github.com/gosnmp/gosnmp v1.43.2/go.mod h1:smHIwoaqr1M+HTAEd7+mKkPs8lp3Lf/U+htPUql1Q3c=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/josharian/native v1.0.0 h1:Ts/E8zCSEsG17dUqv7joXJFybuMLjQfWE04tsBODTxk=
|
||||
github.com/josharian/native v1.0.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mdlayher/arp v0.0.0-20220512170110-6706a2966875 h1:ql8x//rJsHMjS+qqEag8n3i4azw1QneKh5PieH9UEbY=
|
||||
github.com/mdlayher/arp v0.0.0-20220512170110-6706a2966875/go.mod h1:kfOoFJuHWp76v1RgZCb9/gVUc7XdY877S2uVYbNliGc=
|
||||
github.com/mdlayher/ethernet v0.0.0-20220221185849-529eae5b6118 h1:2oDp6OOhLxQ9JBoUuysVz9UZ9uI6oLUbvAZu0x8o+vE=
|
||||
github.com/mdlayher/ethernet v0.0.0-20220221185849-529eae5b6118/go.mod h1:ZFUnHIVchZ9lJoWoEGUg8Q3M4U8aNNWA3CVSUTkW4og=
|
||||
github.com/mdlayher/packet v1.0.0 h1:InhZJbdShQYt6XV2GPj5XHxChzOfhJJOMbvnGAmOfQ8=
|
||||
github.com/mdlayher/packet v1.0.0/go.mod h1:eE7/ctqDhoiRhQ44ko5JZU2zxB88g+JH/6jmnjzPjOU=
|
||||
github.com/mdlayher/socket v0.2.1 h1:F2aaOwb53VsBE+ebRS9bLd7yPOfYUMC8lOODdCBDY6w=
|
||||
github.com/mdlayher/socket v0.2.1/go.mod h1:QLlNPkFR88mRUNQIzRBMfXxwKal8H7u1h3bL1CV+f0E=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
|
||||
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65 h1:+rhAzEzT3f4JtomfC371qB+0Ola2caSKcY69NUBZrRQ=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
|
||||
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
|
||||
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.20.0 h1:45Or8mQfbUqJOG9WaxvlFYOAQO0lQ5RvqBcFCXngjxk=
|
||||
modernc.org/cc/v4 v4.20.0/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
||||
modernc.org/ccgo/v4 v4.16.0 h1:ofwORa6vx2FMm0916/CkZjpFPSR70VwTjUCe2Eg5BnA=
|
||||
modernc.org/ccgo/v4 v4.16.0/go.mod h1:dkNyWIjFrVIZ68DTo36vHK+6/ShBn4ysU61So6PIqCI=
|
||||
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
|
||||
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
|
||||
modernc.org/libc v1.49.3 h1:j2MRCRdwJI2ls/sGbeSk0t2bypOG/uvPZUsGQFDulqg=
|
||||
modernc.org/libc v1.49.3/go.mod h1:yMZuGkn7pXbKfoT/M35gFJOAEdSKdxL0q64sF7KqCDo=
|
||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
||||
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
|
||||
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
||||
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
|
||||
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
|
||||
modernc.org/sqlite v1.29.10 h1:3u93dz83myFnMilBGCOLbr+HjklS6+5rJLx4q86RDAg=
|
||||
modernc.org/sqlite v1.29.10/go.mod h1:ItX2a1OVGgNsFh6Dv60JQvGfJfTPHPVpV6DF59akYOA=
|
||||
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
||||
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
@@ -1,195 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Config holds the full agent configuration loaded from config.yaml.
|
||||
type Config struct {
|
||||
Site string `yaml:"site"` // LUD or BAR
|
||||
LogLevel string `yaml:"log_level"` // debug | info | warn | error
|
||||
SetupComplete bool `yaml:"setup_complete"` // false → show setup wizard on first run
|
||||
|
||||
DB struct {
|
||||
Path string `yaml:"path"`
|
||||
} `yaml:"db"`
|
||||
|
||||
Web struct {
|
||||
Addr string `yaml:"addr"`
|
||||
Token string `yaml:"token"`
|
||||
} `yaml:"web"`
|
||||
|
||||
Nexus struct {
|
||||
URL string `yaml:"url"`
|
||||
APIKey string `yaml:"api_key"`
|
||||
} `yaml:"nexus"`
|
||||
|
||||
OfflineAfter time.Duration `yaml:"offline_after"` // mark hosts offline after this duration
|
||||
|
||||
Alert AlertConfig `yaml:"alert"`
|
||||
|
||||
Modules struct {
|
||||
ARPDiscovery ARPConfig `yaml:"arp_discovery"`
|
||||
ADSync ADConfig `yaml:"ad_sync"`
|
||||
SiteMonitoring SiteMonConfig `yaml:"site_monitoring"`
|
||||
PortScan PortScanConfig `yaml:"port_scan"`
|
||||
SNMP SNMPConfig `yaml:"snmp"`
|
||||
NexusReporter ReporterConfig `yaml:"nexus_reporter"`
|
||||
} `yaml:"modules"`
|
||||
}
|
||||
|
||||
// ADConfig holds settings for the ad_sync module.
|
||||
type ADConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Interval time.Duration `yaml:"interval"`
|
||||
Server string `yaml:"server"` // DC hostname or IP
|
||||
Port int `yaml:"port"` // default 389
|
||||
BindDN string `yaml:"bind_dn"` // svc-scanner@winkel.local
|
||||
BindPassword string `yaml:"bind_password"`
|
||||
SearchBase string `yaml:"search_base"` // DC=winkel,DC=local
|
||||
TLS bool `yaml:"tls"` // use LDAPS
|
||||
}
|
||||
|
||||
// SiteMonConfig holds settings for the site_monitoring module.
|
||||
type SiteMonConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Interval time.Duration `yaml:"interval"`
|
||||
}
|
||||
|
||||
// PortScanConfig holds settings for the port_scan module.
|
||||
type PortScanConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Interval time.Duration `yaml:"interval"`
|
||||
Ports []int `yaml:"ports"` // empty = use defaults
|
||||
Timeout time.Duration `yaml:"timeout"` // per-port connect timeout
|
||||
}
|
||||
|
||||
// SNMPConfig holds settings for the snmp module.
|
||||
type SNMPConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Interval time.Duration `yaml:"interval"`
|
||||
Community string `yaml:"community"` // default community string
|
||||
}
|
||||
|
||||
// ReporterConfig holds settings for the nexus_reporter module.
|
||||
type ReporterConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Interval time.Duration `yaml:"interval"`
|
||||
}
|
||||
|
||||
// AlertConfig holds notification settings.
|
||||
type AlertConfig struct {
|
||||
SMTP struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
Username string `yaml:"username"`
|
||||
Password string `yaml:"password"`
|
||||
From string `yaml:"from"`
|
||||
To string `yaml:"to"`
|
||||
} `yaml:"smtp"`
|
||||
NexusEnabled bool `yaml:"nexus_enabled"`
|
||||
}
|
||||
|
||||
// ARPConfig holds settings for the arp_discovery module.
|
||||
type ARPConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Interval time.Duration `yaml:"interval"`
|
||||
Subnets []string `yaml:"subnets"`
|
||||
Interface string `yaml:"interface"`
|
||||
}
|
||||
|
||||
// Load reads and validates the YAML config at path.
|
||||
func Load(path string) (*Config, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open config %q: %w", path, err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var cfg Config
|
||||
if err := yaml.NewDecoder(f).Decode(&cfg); err != nil {
|
||||
return nil, fmt.Errorf("decode config: %w", err)
|
||||
}
|
||||
|
||||
if err := cfg.applyDefaults(); err != nil {
|
||||
return nil, fmt.Errorf("config validation: %w", err)
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// Save writes the config back to path in YAML format.
|
||||
func Save(path string, cfg *Config) error {
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create config %q: %w", path, err)
|
||||
}
|
||||
defer f.Close()
|
||||
if err := yaml.NewEncoder(f).Encode(cfg); err != nil {
|
||||
return fmt.Errorf("encode config: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Config) applyDefaults() error {
|
||||
if c.Site == "" {
|
||||
return fmt.Errorf("site is required (LUD or BAR)")
|
||||
}
|
||||
if c.Site != "LUD" && c.Site != "BAR" {
|
||||
return fmt.Errorf("site must be LUD or BAR, got %q", c.Site)
|
||||
}
|
||||
if c.LogLevel == "" {
|
||||
c.LogLevel = "info"
|
||||
}
|
||||
if c.DB.Path == "" {
|
||||
c.DB.Path = "/var/lib/nexus-scanner/scanner.db"
|
||||
}
|
||||
if c.Web.Addr == "" {
|
||||
c.Web.Addr = ":8080"
|
||||
}
|
||||
if c.Modules.ARPDiscovery.Interval == 0 {
|
||||
c.Modules.ARPDiscovery.Interval = 5 * time.Minute
|
||||
}
|
||||
if c.Modules.ARPDiscovery.Interface == "" {
|
||||
c.Modules.ARPDiscovery.Interface = "eth0"
|
||||
}
|
||||
if c.Modules.ADSync.Port == 0 {
|
||||
c.Modules.ADSync.Port = 389
|
||||
}
|
||||
if c.Modules.ADSync.Interval == 0 {
|
||||
c.Modules.ADSync.Interval = 30 * time.Minute
|
||||
}
|
||||
if c.Modules.ADSync.SearchBase == "" && c.Modules.ADSync.Server != "" {
|
||||
c.Modules.ADSync.SearchBase = "DC=winkel,DC=local"
|
||||
}
|
||||
if c.Modules.SiteMonitoring.Interval == 0 {
|
||||
c.Modules.SiteMonitoring.Interval = time.Minute
|
||||
}
|
||||
if c.Modules.PortScan.Interval == 0 {
|
||||
c.Modules.PortScan.Interval = 15 * time.Minute
|
||||
}
|
||||
if c.Modules.PortScan.Timeout == 0 {
|
||||
c.Modules.PortScan.Timeout = 500 * time.Millisecond
|
||||
}
|
||||
if c.Modules.SNMP.Interval == 0 {
|
||||
c.Modules.SNMP.Interval = 5 * time.Minute
|
||||
}
|
||||
if c.Modules.SNMP.Community == "" {
|
||||
c.Modules.SNMP.Community = "public"
|
||||
}
|
||||
if c.Modules.NexusReporter.Interval == 0 {
|
||||
c.Modules.NexusReporter.Interval = 10 * time.Minute
|
||||
}
|
||||
if c.OfflineAfter == 0 {
|
||||
c.OfflineAfter = 15 * time.Minute
|
||||
}
|
||||
if c.Alert.SMTP.Port == 0 {
|
||||
c.Alert.SMTP.Port = 587
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,659 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// Store wraps the SQLite database.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// Open opens (or creates) the SQLite database at path and runs migrations.
|
||||
func Open(path string) (*Store, error) {
|
||||
conn, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open sqlite at %q: %w", path, err)
|
||||
}
|
||||
|
||||
// SQLite works best with a single writer connection.
|
||||
conn.SetMaxOpenConns(1)
|
||||
|
||||
// Recommended pragmas for reliability and performance.
|
||||
for _, pragma := range []string{
|
||||
"PRAGMA journal_mode=WAL",
|
||||
"PRAGMA busy_timeout=5000",
|
||||
"PRAGMA foreign_keys=ON",
|
||||
} {
|
||||
if _, err := conn.Exec(pragma); err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("pragma %q: %w", pragma, err)
|
||||
}
|
||||
}
|
||||
|
||||
s := &Store{db: conn}
|
||||
if err := s.migrate(); err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("migrate: %w", err)
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Close closes the underlying database connection.
|
||||
func (s *Store) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
func (s *Store) migrate() error {
|
||||
_, err := s.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS host_ports (
|
||||
ip TEXT NOT NULL,
|
||||
port INTEGER NOT NULL,
|
||||
scanned_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (ip, port)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS snmp_targets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
ip TEXT NOT NULL,
|
||||
community TEXT NOT NULL DEFAULT 'public',
|
||||
version TEXT NOT NULL DEFAULT 'v2c',
|
||||
type TEXT NOT NULL DEFAULT 'generic',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS snmp_results (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
target_id INTEGER NOT NULL,
|
||||
oid_name TEXT NOT NULL,
|
||||
value TEXT NOT NULL DEFAULT '',
|
||||
scanned_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(target_id, oid_name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS module_states (
|
||||
name TEXT PRIMARY KEY,
|
||||
enabled INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS monitor_checks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS monitor_results (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
check_id INTEGER NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
latency_ms INTEGER NOT NULL DEFAULT 0,
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
checked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_monitor_results_check
|
||||
ON monitor_results(check_id, checked_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ad_computers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
cn TEXT NOT NULL UNIQUE,
|
||||
dn TEXT NOT NULL DEFAULT '',
|
||||
os TEXT NOT NULL DEFAULT '',
|
||||
os_version TEXT NOT NULL DEFAULT '',
|
||||
owner TEXT NOT NULL DEFAULT '',
|
||||
department TEXT NOT NULL DEFAULT '',
|
||||
last_logon DATETIME,
|
||||
ad_created DATETIME,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS hosts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ip TEXT NOT NULL UNIQUE,
|
||||
mac TEXT NOT NULL,
|
||||
vendor TEXT NOT NULL DEFAULT '',
|
||||
hostname TEXT NOT NULL DEFAULT '',
|
||||
site TEXT NOT NULL,
|
||||
first_seen DATETIME NOT NULL,
|
||||
last_seen DATETIME NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'online'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS scan_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
module TEXT NOT NULL,
|
||||
started_at DATETIME NOT NULL,
|
||||
ended_at DATETIME,
|
||||
status TEXT NOT NULL DEFAULT 'running',
|
||||
error TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
// Host represents a discovered network device.
|
||||
type Host struct {
|
||||
ID int64
|
||||
IP string
|
||||
MAC string
|
||||
Vendor string
|
||||
Hostname string
|
||||
Site string
|
||||
FirstSeen time.Time
|
||||
LastSeen time.Time
|
||||
Status string
|
||||
}
|
||||
|
||||
// UpsertHost inserts a new host or updates MAC, vendor and last_seen if the
|
||||
// IP already exists. first_seen is never overwritten on update.
|
||||
func (s *Store) UpsertHost(h Host) error {
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO hosts (ip, mac, vendor, hostname, site, first_seen, last_seen, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'online')
|
||||
ON CONFLICT(ip) DO UPDATE SET
|
||||
mac = excluded.mac,
|
||||
vendor = CASE WHEN excluded.vendor != '' THEN excluded.vendor ELSE vendor END,
|
||||
hostname = CASE WHEN excluded.hostname != '' THEN excluded.hostname ELSE hostname END,
|
||||
last_seen = excluded.last_seen,
|
||||
status = 'online'
|
||||
`, h.IP, h.MAC, h.Vendor, h.Hostname, h.Site, h.FirstSeen.UTC(), h.LastSeen.UTC())
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert host %s: %w", h.IP, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListHosts returns all hosts ordered by last_seen desc.
|
||||
// Pass an empty site to return all sites.
|
||||
func (s *Store) ListHosts(site string) ([]Host, error) {
|
||||
query := `
|
||||
SELECT id, ip, mac, vendor, hostname, site, first_seen, last_seen, status
|
||||
FROM hosts`
|
||||
var args []any
|
||||
if site != "" {
|
||||
query += ` WHERE site = ?`
|
||||
args = append(args, site)
|
||||
}
|
||||
query += ` ORDER BY last_seen DESC`
|
||||
|
||||
rows, err := s.db.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list hosts: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var hosts []Host
|
||||
for rows.Next() {
|
||||
var h Host
|
||||
if err := rows.Scan(
|
||||
&h.ID, &h.IP, &h.MAC, &h.Vendor, &h.Hostname,
|
||||
&h.Site, &h.FirstSeen, &h.LastSeen, &h.Status,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan host row: %w", err)
|
||||
}
|
||||
hosts = append(hosts, h)
|
||||
}
|
||||
return hosts, rows.Err()
|
||||
}
|
||||
|
||||
// CountHosts returns the total number of hosts in the database.
|
||||
func (s *Store) CountHosts() (int, error) {
|
||||
var n int
|
||||
if err := s.db.QueryRow(`SELECT COUNT(*) FROM hosts`).Scan(&n); err != nil {
|
||||
return 0, fmt.Errorf("count hosts: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// BeginScan records the start of a module scan run and returns the run ID.
|
||||
func (s *Store) BeginScan(module string) (int64, error) {
|
||||
res, err := s.db.Exec(`
|
||||
INSERT INTO scan_runs (module, started_at) VALUES (?, ?)
|
||||
`, module, time.Now().UTC())
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("begin scan: %w", err)
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// EndScan marks a scan run as completed (status ok or error).
|
||||
func (s *Store) EndScan(id int64, scanErr error) error {
|
||||
status, errMsg := "ok", ""
|
||||
if scanErr != nil {
|
||||
status, errMsg = "error", scanErr.Error()
|
||||
}
|
||||
_, err := s.db.Exec(`
|
||||
UPDATE scan_runs SET ended_at = ?, status = ?, error = ? WHERE id = ?
|
||||
`, time.Now().UTC(), status, errMsg, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("end scan %d: %w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ADComputer represents a computer object from Active Directory.
|
||||
type ADComputer struct {
|
||||
ID int64
|
||||
CN string
|
||||
DN string
|
||||
OS string
|
||||
OSVersion string
|
||||
Owner string
|
||||
Department string
|
||||
LastLogon time.Time
|
||||
ADCreated time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// UpsertADComputer inserts or updates an AD computer record by CN.
|
||||
func (s *Store) UpsertADComputer(c ADComputer) error {
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO ad_computers (cn, dn, os, os_version, owner, department, last_logon, ad_created, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(cn) DO UPDATE SET
|
||||
dn = excluded.dn,
|
||||
os = excluded.os,
|
||||
os_version = excluded.os_version,
|
||||
owner = excluded.owner,
|
||||
department = excluded.department,
|
||||
last_logon = excluded.last_logon,
|
||||
updated_at = excluded.updated_at
|
||||
`, c.CN, c.DN, c.OS, c.OSVersion, c.Owner, c.Department,
|
||||
nullTime(c.LastLogon), nullTime(c.ADCreated), time.Now().UTC())
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert ad computer %s: %w", c.CN, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListADComputers returns all known AD computers.
|
||||
func (s *Store) ListADComputers() ([]ADComputer, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, cn, dn, os, os_version, owner, department, last_logon, ad_created, updated_at
|
||||
FROM ad_computers ORDER BY cn
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list ad computers: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []ADComputer
|
||||
for rows.Next() {
|
||||
var c ADComputer
|
||||
var lastLogon, adCreated *time.Time
|
||||
if err := rows.Scan(&c.ID, &c.CN, &c.DN, &c.OS, &c.OSVersion,
|
||||
&c.Owner, &c.Department, &lastLogon, &adCreated, &c.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if lastLogon != nil {
|
||||
c.LastLogon = *lastLogon
|
||||
}
|
||||
if adCreated != nil {
|
||||
c.ADCreated = *adCreated
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// UpdateHostnameFromAD sets the hostname of a host matching the given IP.
|
||||
func (s *Store) UpdateHostnameFromAD(hostname string) error {
|
||||
_, err := s.db.Exec(`
|
||||
UPDATE hosts SET hostname = ? WHERE hostname = '' AND ip IN (
|
||||
SELECT ip FROM hosts WHERE LOWER(hostname) = LOWER(?)
|
||||
)
|
||||
`, hostname, hostname)
|
||||
return err
|
||||
}
|
||||
|
||||
func nullTime(t time.Time) any {
|
||||
if t.IsZero() {
|
||||
return nil
|
||||
}
|
||||
return t.UTC()
|
||||
}
|
||||
|
||||
// LastScanTime returns when the given module last ran successfully.
|
||||
func (s *Store) LastScanTime(module string) (time.Time, error) {
|
||||
var t time.Time
|
||||
err := s.db.QueryRow(`
|
||||
SELECT ended_at FROM scan_runs
|
||||
WHERE module = ? AND status = 'ok'
|
||||
ORDER BY ended_at DESC LIMIT 1
|
||||
`, module).Scan(&t)
|
||||
if err == sql.ErrNoRows {
|
||||
return time.Time{}, nil
|
||||
}
|
||||
return t, err
|
||||
}
|
||||
|
||||
/* ── Site Monitoring ──────────────────────────────────────────────── */
|
||||
|
||||
// MonitorCheck is a configured availability check (ping/http/tcp).
|
||||
type MonitorCheck struct {
|
||||
ID int64
|
||||
Name string
|
||||
Type string // ping | http | tcp
|
||||
Target string
|
||||
Enabled bool
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// MonitorResult is a single check execution result.
|
||||
type MonitorResult struct {
|
||||
ID int64
|
||||
CheckID int64
|
||||
Status string // online | offline
|
||||
LatencyMS int
|
||||
Error string
|
||||
CheckedAt time.Time
|
||||
}
|
||||
|
||||
// ListMonitorChecks returns all configured checks ordered by creation time.
|
||||
func (s *Store) ListMonitorChecks() ([]MonitorCheck, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, name, type, target, enabled, created_at
|
||||
FROM monitor_checks ORDER BY id
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list monitor checks: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []MonitorCheck
|
||||
for rows.Next() {
|
||||
var c MonitorCheck
|
||||
var ena int
|
||||
if err := rows.Scan(&c.ID, &c.Name, &c.Type, &c.Target, &ena, &c.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.Enabled = ena == 1
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// AddMonitorCheck inserts a new check definition.
|
||||
func (s *Store) AddMonitorCheck(name, typ, target string) (int64, error) {
|
||||
res, err := s.db.Exec(`
|
||||
INSERT INTO monitor_checks (name, type, target) VALUES (?, ?, ?)
|
||||
`, name, typ, target)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("add monitor check: %w", err)
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// DeleteMonitorCheck removes a check and all its results.
|
||||
func (s *Store) DeleteMonitorCheck(id int64) error {
|
||||
if _, err := s.db.Exec(`DELETE FROM monitor_results WHERE check_id = ?`, id); err != nil {
|
||||
return fmt.Errorf("delete monitor results: %w", err)
|
||||
}
|
||||
if _, err := s.db.Exec(`DELETE FROM monitor_checks WHERE id = ?`, id); err != nil {
|
||||
return fmt.Errorf("delete monitor check: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// InsertMonitorResult stores one check execution result.
|
||||
func (s *Store) InsertMonitorResult(r MonitorResult) error {
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO monitor_results (check_id, status, latency_ms, error, checked_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`, r.CheckID, r.Status, r.LatencyMS, r.Error, r.CheckedAt.UTC())
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert monitor result: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LatestMonitorResults returns the most recent result for each check_id.
|
||||
func (s *Store) LatestMonitorResults() (map[int64]MonitorResult, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT r.id, r.check_id, r.status, r.latency_ms, r.error, r.checked_at
|
||||
FROM monitor_results r
|
||||
INNER JOIN (
|
||||
SELECT check_id, MAX(id) as max_id FROM monitor_results GROUP BY check_id
|
||||
) latest ON r.id = latest.max_id
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("latest monitor results: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make(map[int64]MonitorResult)
|
||||
for rows.Next() {
|
||||
var r MonitorResult
|
||||
if err := rows.Scan(&r.ID, &r.CheckID, &r.Status, &r.LatencyMS, &r.Error, &r.CheckedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[r.CheckID] = r
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// MonitorResultHistory returns the last limit results for a given check.
|
||||
func (s *Store) MonitorResultHistory(checkID int64, limit int) ([]MonitorResult, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, check_id, status, latency_ms, error, checked_at
|
||||
FROM monitor_results WHERE check_id = ?
|
||||
ORDER BY checked_at DESC LIMIT ?
|
||||
`, checkID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("monitor history: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []MonitorResult
|
||||
for rows.Next() {
|
||||
var r MonitorResult
|
||||
if err := rows.Scan(&r.ID, &r.CheckID, &r.Status, &r.LatencyMS, &r.Error, &r.CheckedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
// Reverse so oldest-first for sparkline rendering.
|
||||
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
|
||||
out[i], out[j] = out[j], out[i]
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
/* ── Module States ────────────────────────────────────────────────── */
|
||||
|
||||
// SetModuleEnabled stores the enabled state for a module.
|
||||
func (s *Store) SetModuleEnabled(name string, enabled bool) error {
|
||||
v := 0
|
||||
if enabled {
|
||||
v = 1
|
||||
}
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO module_states (name, enabled) VALUES (?,?)
|
||||
ON CONFLICT(name) DO UPDATE SET enabled=excluded.enabled
|
||||
`, name, v)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetModuleEnabled returns whether a module is enabled (default true if not set).
|
||||
func (s *Store) GetModuleEnabled(name string) bool {
|
||||
var v int
|
||||
err := s.db.QueryRow(`SELECT enabled FROM module_states WHERE name=?`, name).Scan(&v)
|
||||
if err != nil {
|
||||
return true // default: enabled
|
||||
}
|
||||
return v == 1
|
||||
}
|
||||
|
||||
/* ── Port Scan ────────────────────────────────────────────────────── */
|
||||
|
||||
// UpsertHostPorts replaces all open ports for an IP.
|
||||
func (s *Store) UpsertHostPorts(ip string, ports []int) error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.Exec(`DELETE FROM host_ports WHERE ip = ?`, ip); err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
for _, p := range ports {
|
||||
if _, err := tx.Exec(`INSERT INTO host_ports (ip, port, scanned_at) VALUES (?,?,?)`, ip, p, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// GetHostPorts returns all open ports for an IP.
|
||||
func (s *Store) GetHostPorts(ip string) ([]int, error) {
|
||||
rows, err := s.db.Query(`SELECT port FROM host_ports WHERE ip = ? ORDER BY port`, ip)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var ports []int
|
||||
for rows.Next() {
|
||||
var p int
|
||||
rows.Scan(&p)
|
||||
ports = append(ports, p)
|
||||
}
|
||||
return ports, rows.Err()
|
||||
}
|
||||
|
||||
// ListAllHostPorts returns a map of IP → open ports.
|
||||
func (s *Store) ListAllHostPorts() (map[string][]int, error) {
|
||||
rows, err := s.db.Query(`SELECT ip, port FROM host_ports ORDER BY ip, port`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make(map[string][]int)
|
||||
for rows.Next() {
|
||||
var ip string
|
||||
var port int
|
||||
rows.Scan(&ip, &port)
|
||||
out[ip] = append(out[ip], port)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// MarkStaleHostsOffline marks hosts as offline if last_seen is older than threshold.
|
||||
func (s *Store) MarkStaleHostsOffline(olderThan time.Duration) (int, error) {
|
||||
threshold := time.Now().UTC().Add(-olderThan)
|
||||
res, err := s.db.Exec(`UPDATE hosts SET status='offline' WHERE status='online' AND last_seen < ?`, threshold)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("mark stale offline: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return int(n), nil
|
||||
}
|
||||
|
||||
/* ── SNMP ─────────────────────────────────────────────────────────── */
|
||||
|
||||
// SNMPTarget is a configured SNMP polling target.
|
||||
type SNMPTarget struct {
|
||||
ID int64
|
||||
Name string
|
||||
IP string
|
||||
Community string
|
||||
Version string
|
||||
Type string // generic | printer | switch
|
||||
Enabled bool
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// SNMPResult is the latest value for one OID on one target.
|
||||
type SNMPResult struct {
|
||||
ID int64
|
||||
TargetID int64
|
||||
OIDName string
|
||||
Value string
|
||||
ScannedAt time.Time
|
||||
}
|
||||
|
||||
// ListSNMPTargets returns all SNMP targets.
|
||||
func (s *Store) ListSNMPTargets() ([]SNMPTarget, error) {
|
||||
rows, err := s.db.Query(`SELECT id,name,ip,community,version,type,enabled,created_at FROM snmp_targets ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []SNMPTarget
|
||||
for rows.Next() {
|
||||
var t SNMPTarget
|
||||
var ena int
|
||||
rows.Scan(&t.ID, &t.Name, &t.IP, &t.Community, &t.Version, &t.Type, &ena, &t.CreatedAt)
|
||||
t.Enabled = ena == 1
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// AddSNMPTarget inserts a new SNMP target.
|
||||
func (s *Store) AddSNMPTarget(name, ip, community, version, typ string) (int64, error) {
|
||||
res, err := s.db.Exec(`INSERT INTO snmp_targets (name,ip,community,version,type) VALUES (?,?,?,?,?)`,
|
||||
name, ip, community, version, typ)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// DeleteSNMPTarget removes a target and its results.
|
||||
func (s *Store) DeleteSNMPTarget(id int64) error {
|
||||
s.db.Exec(`DELETE FROM snmp_results WHERE target_id = ?`, id)
|
||||
_, err := s.db.Exec(`DELETE FROM snmp_targets WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpsertSNMPResult inserts or updates one OID result.
|
||||
func (s *Store) UpsertSNMPResult(r SNMPResult) error {
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO snmp_results (target_id, oid_name, value, scanned_at)
|
||||
VALUES (?,?,?,?)
|
||||
ON CONFLICT(target_id, oid_name) DO UPDATE SET value=excluded.value, scanned_at=excluded.scanned_at
|
||||
`, r.TargetID, r.OIDName, r.Value, r.ScannedAt.UTC())
|
||||
return err
|
||||
}
|
||||
|
||||
// LatestSNMPResults returns all latest OID values keyed by target_id.
|
||||
func (s *Store) LatestSNMPResults() (map[int64]map[string]string, error) {
|
||||
rows, err := s.db.Query(`SELECT target_id, oid_name, value FROM snmp_results ORDER BY target_id, oid_name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make(map[int64]map[string]string)
|
||||
for rows.Next() {
|
||||
var tid int64
|
||||
var k, v string
|
||||
rows.Scan(&tid, &k, &v)
|
||||
if out[tid] == nil {
|
||||
out[tid] = make(map[string]string)
|
||||
}
|
||||
out[tid][k] = v
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// CleanupMonitorResults deletes old results, keeping the last keepPerCheck per check.
|
||||
func (s *Store) CleanupMonitorResults(keepPerCheck int) error {
|
||||
_, err := s.db.Exec(`
|
||||
DELETE FROM monitor_results WHERE id NOT IN (
|
||||
SELECT id FROM (
|
||||
SELECT id, ROW_NUMBER() OVER (
|
||||
PARTITION BY check_id ORDER BY checked_at DESC
|
||||
) AS rn FROM monitor_results
|
||||
) WHERE rn <= ?
|
||||
)
|
||||
`, keepPerCheck)
|
||||
return err
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
package module
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Module is the interface every scanner module must implement.
|
||||
type Module interface {
|
||||
// Name returns the unique identifier for this module (e.g. "arp_discovery").
|
||||
Name() string
|
||||
|
||||
// Interval returns how often the scheduler should run this module.
|
||||
// A zero duration means the module runs once on startup only.
|
||||
Interval() time.Duration
|
||||
|
||||
// Run executes one scan cycle. It must respect ctx cancellation.
|
||||
Run(ctx context.Context) error
|
||||
}
|
||||
|
||||
// Registry holds all registered modules.
|
||||
type Registry struct {
|
||||
modules []Module
|
||||
}
|
||||
|
||||
// NewRegistry returns an empty module registry.
|
||||
func NewRegistry() *Registry {
|
||||
return &Registry{}
|
||||
}
|
||||
|
||||
// Register adds a module to the registry.
|
||||
func (r *Registry) Register(m Module) {
|
||||
r.modules = append(r.modules, m)
|
||||
}
|
||||
|
||||
// All returns all registered modules.
|
||||
func (r *Registry) All() []Module {
|
||||
return r.modules
|
||||
}
|
||||
|
||||
// Get returns the module with the given name.
|
||||
func (r *Registry) Get(name string) (Module, error) {
|
||||
for _, m := range r.modules {
|
||||
if m.Name() == name {
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("module %q not registered", name)
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
// Package adsync queries Active Directory for computer objects and stores
|
||||
// them in the local database. It enriches existing hosts with their AD
|
||||
// computer name and department, and records computers that are in AD but
|
||||
// not yet seen on the network (useful for finding offline/missing devices).
|
||||
package adsync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
|
||||
"github.com/cereda-systems/nexus-scanner/internal/config"
|
||||
"github.com/cereda-systems/nexus-scanner/internal/db"
|
||||
)
|
||||
|
||||
// Module queries Active Directory for all computer objects.
|
||||
type Module struct {
|
||||
site string
|
||||
cfg config.ADConfig
|
||||
store *db.Store
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// New returns a new AD sync module.
|
||||
func New(site string, cfg config.ADConfig, store *db.Store) *Module {
|
||||
return &Module{
|
||||
site: site,
|
||||
cfg: cfg,
|
||||
store: store,
|
||||
log: slog.With("module", "ad_sync"),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Module) Name() string { return "ad_sync" }
|
||||
func (m *Module) Interval() time.Duration { return m.cfg.Interval }
|
||||
|
||||
// Run executes one AD sync cycle.
|
||||
func (m *Module) Run(ctx context.Context) error {
|
||||
scanID, err := m.store.BeginScan(m.Name())
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin scan: %w", err)
|
||||
}
|
||||
scanErr := m.sync(ctx)
|
||||
if endErr := m.store.EndScan(scanID, scanErr); endErr != nil {
|
||||
m.log.Error("record scan end", "err", endErr)
|
||||
}
|
||||
return scanErr
|
||||
}
|
||||
|
||||
func (m *Module) sync(ctx context.Context) error {
|
||||
addr := fmt.Sprintf("%s:%d", m.cfg.Server, m.cfg.Port)
|
||||
|
||||
var conn *ldap.Conn
|
||||
var err error
|
||||
if m.cfg.TLS {
|
||||
conn, err = ldap.DialURL("ldaps://" + addr)
|
||||
} else {
|
||||
conn, err = ldap.DialURL("ldap://" + addr)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("ldap dial %s: %w", addr, err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if err := conn.Bind(m.cfg.BindDN, m.cfg.BindPassword); err != nil {
|
||||
return fmt.Errorf("ldap bind as %s: %w", m.cfg.BindDN, err)
|
||||
}
|
||||
|
||||
computers, err := m.searchComputers(conn)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ldap search: %w", err)
|
||||
}
|
||||
|
||||
m.log.Info("computers found in AD", "count", len(computers))
|
||||
|
||||
saved := 0
|
||||
for _, c := range computers {
|
||||
if err := m.store.UpsertADComputer(c); err != nil {
|
||||
m.log.Error("upsert ad computer", "cn", c.CN, "err", err)
|
||||
continue
|
||||
}
|
||||
saved++
|
||||
m.log.Debug("ad computer synced", "cn", c.CN, "os", c.OS, "dept", c.Department)
|
||||
}
|
||||
|
||||
m.log.Info("ad sync complete", "synced", saved, "total", len(computers))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Module) searchComputers(conn *ldap.Conn) ([]db.ADComputer, error) {
|
||||
req := ldap.NewSearchRequest(
|
||||
m.cfg.SearchBase,
|
||||
ldap.ScopeWholeSubtree,
|
||||
ldap.NeverDerefAliases,
|
||||
0, 0, false,
|
||||
"(&(objectClass=computer)(objectCategory=Computer))",
|
||||
[]string{
|
||||
"cn",
|
||||
"distinguishedName",
|
||||
"operatingSystem",
|
||||
"operatingSystemVersion",
|
||||
"lastLogonTimestamp",
|
||||
"whenCreated",
|
||||
"description",
|
||||
"managedBy",
|
||||
},
|
||||
nil,
|
||||
)
|
||||
|
||||
result, err := conn.SearchWithPaging(req, 500)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ldap search computers: %w", err)
|
||||
}
|
||||
|
||||
var computers []db.ADComputer
|
||||
for _, entry := range result.Entries {
|
||||
c := db.ADComputer{
|
||||
CN: entry.GetAttributeValue("cn"),
|
||||
DN: entry.GetAttributeValue("distinguishedName"),
|
||||
OS: entry.GetAttributeValue("operatingSystem"),
|
||||
OSVersion: entry.GetAttributeValue("operatingSystemVersion"),
|
||||
Owner: entry.GetAttributeValue("description"),
|
||||
Department: ouFromDN(entry.GetAttributeValue("distinguishedName")),
|
||||
}
|
||||
|
||||
if raw := entry.GetAttributeValue("lastLogonTimestamp"); raw != "" {
|
||||
c.LastLogon = windowsTimeToGoTime(raw)
|
||||
}
|
||||
if raw := entry.GetAttributeValue("whenCreated"); raw != "" {
|
||||
c.ADCreated = parseLDAPGeneralizedTime(raw)
|
||||
}
|
||||
|
||||
computers = append(computers, c)
|
||||
}
|
||||
|
||||
return computers, nil
|
||||
}
|
||||
|
||||
// ouFromDN extracts the first OU component from a distinguished name.
|
||||
// "CN=IT-NB-02,OU=Notebooks,OU=IT,DC=winkel,DC=local" → "Notebooks"
|
||||
func ouFromDN(dn string) string {
|
||||
for _, part := range strings.Split(dn, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if strings.HasPrefix(strings.ToUpper(part), "OU=") {
|
||||
return part[3:]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// windowsTimeToGoTime converts a Windows LDAP timestamp string (100-ns intervals
|
||||
// since 1601-01-01) to a Go time.Time.
|
||||
func windowsTimeToGoTime(raw string) time.Time {
|
||||
n, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil || n == 0 {
|
||||
return time.Time{}
|
||||
}
|
||||
// Offset between Windows epoch (1601-01-01) and Unix epoch (1970-01-01)
|
||||
// in 100-nanosecond intervals.
|
||||
const windowsToUnix int64 = 116444736000000000
|
||||
unixNano := (n - windowsToUnix) * 100
|
||||
return time.Unix(0, unixNano).UTC()
|
||||
}
|
||||
|
||||
// parseLDAPGeneralizedTime parses LDAP generalizedTime format: "20240101120000.0Z"
|
||||
func parseLDAPGeneralizedTime(raw string) time.Time {
|
||||
// Trim trailing sub-second and timezone info for simple parsing.
|
||||
raw = strings.TrimSuffix(raw, "Z")
|
||||
if idx := strings.Index(raw, "."); idx != -1 {
|
||||
raw = raw[:idx]
|
||||
}
|
||||
t, err := time.Parse("20060102150405", raw)
|
||||
if err != nil {
|
||||
return time.Time{}
|
||||
}
|
||||
return t.UTC()
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
// Package arp implements the arp_discovery scanner module.
|
||||
// It sends ARP requests across configured subnets and records
|
||||
// responding hosts (IP + MAC) in the shared SQLite store.
|
||||
package arp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/netip"
|
||||
"time"
|
||||
|
||||
"github.com/mdlayher/arp"
|
||||
|
||||
"github.com/cereda-systems/nexus-scanner/internal/config"
|
||||
"github.com/cereda-systems/nexus-scanner/internal/db"
|
||||
)
|
||||
|
||||
// Module performs ARP discovery on configured subnets.
|
||||
type Module struct {
|
||||
site string
|
||||
cfg config.ARPConfig
|
||||
store *db.Store
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// New returns a new ARP discovery module.
|
||||
func New(site string, cfg config.ARPConfig, store *db.Store) *Module {
|
||||
return &Module{
|
||||
site: site,
|
||||
cfg: cfg,
|
||||
store: store,
|
||||
log: slog.With("module", "arp_discovery"),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Module) Name() string { return "arp_discovery" }
|
||||
func (m *Module) Interval() time.Duration { return m.cfg.Interval }
|
||||
|
||||
// Run executes one full ARP scan cycle across all configured subnets.
|
||||
func (m *Module) Run(ctx context.Context) error {
|
||||
scanID, err := m.store.BeginScan(m.Name())
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin scan record: %w", err)
|
||||
}
|
||||
|
||||
scanErr := m.scanAllSubnets(ctx)
|
||||
|
||||
if err := m.store.EndScan(scanID, scanErr); err != nil {
|
||||
m.log.Error("failed to record scan end", "err", err)
|
||||
}
|
||||
|
||||
return scanErr
|
||||
}
|
||||
|
||||
func (m *Module) scanAllSubnets(ctx context.Context) error {
|
||||
iface, err := net.InterfaceByName(m.cfg.Interface)
|
||||
if err != nil {
|
||||
return fmt.Errorf("interface %q: %w", m.cfg.Interface, err)
|
||||
}
|
||||
|
||||
client, err := arp.Dial(iface)
|
||||
if err != nil {
|
||||
return fmt.Errorf("arp dial on %s: %w", m.cfg.Interface, err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
for _, subnet := range m.cfg.Subnets {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
if err := m.scanSubnet(ctx, client, subnet); err != nil {
|
||||
// Log and continue — one failed subnet should not abort others.
|
||||
m.log.Error("subnet scan failed", "subnet", subnet, "err", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Module) scanSubnet(ctx context.Context, client *arp.Client, cidr string) error {
|
||||
prefix, err := netip.ParsePrefix(cidr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse prefix %q: %w", cidr, err)
|
||||
}
|
||||
prefix = prefix.Masked()
|
||||
|
||||
targets := hostsInPrefix(prefix)
|
||||
m.log.Info("scanning subnet", "subnet", cidr, "targets", len(targets))
|
||||
|
||||
// Send all ARP requests first, then collect responses.
|
||||
// Set a deadline so Read() returns after silence.
|
||||
if err := client.SetDeadline(time.Now().Add(3 * time.Second)); err != nil {
|
||||
return fmt.Errorf("set deadline: %w", err)
|
||||
}
|
||||
|
||||
for _, ip := range targets {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
if err := client.Request(ip); err != nil {
|
||||
m.log.Debug("arp request failed", "ip", ip, "err", err)
|
||||
}
|
||||
|
||||
// Small delay to avoid flooding the network.
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
}
|
||||
|
||||
// Collect all responses until the deadline fires.
|
||||
found := 0
|
||||
for {
|
||||
pkt, _, err := client.Read()
|
||||
if err != nil {
|
||||
// Deadline reached — done reading.
|
||||
break
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
host := db.Host{
|
||||
IP: pkt.SenderIP.String(),
|
||||
MAC: pkt.SenderHardwareAddr.String(),
|
||||
Site: m.site,
|
||||
FirstSeen: now,
|
||||
LastSeen: now,
|
||||
}
|
||||
|
||||
if err := m.store.UpsertHost(host); err != nil {
|
||||
m.log.Error("upsert host", "ip", host.IP, "err", err)
|
||||
continue
|
||||
}
|
||||
|
||||
m.log.Info("host discovered", "ip", host.IP, "mac", host.MAC)
|
||||
found++
|
||||
}
|
||||
|
||||
m.log.Info("subnet scan done", "subnet", cidr, "found", found)
|
||||
return nil
|
||||
}
|
||||
|
||||
// hostsInPrefix returns all usable host addresses in a prefix,
|
||||
// excluding the network address and the broadcast address.
|
||||
//
|
||||
// Example: 192.168.0.0/24 → 192.168.0.1 … 192.168.0.254
|
||||
func hostsInPrefix(prefix netip.Prefix) []netip.Addr {
|
||||
var addrs []netip.Addr
|
||||
|
||||
// Start at network address + 1 (skip network address itself).
|
||||
addr := prefix.Masked().Addr().Next()
|
||||
|
||||
for prefix.Contains(addr) {
|
||||
next := addr.Next()
|
||||
// Stop before the broadcast address (last address in prefix).
|
||||
if !prefix.Contains(next) {
|
||||
break
|
||||
}
|
||||
addrs = append(addrs, addr)
|
||||
addr = next
|
||||
}
|
||||
|
||||
return addrs
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
package arp_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/cereda-systems/nexus-scanner/internal/db"
|
||||
)
|
||||
|
||||
// helper creates a temp SQLite store and registers cleanup.
|
||||
func newTestStore(t *testing.T) *db.Store {
|
||||
t.Helper()
|
||||
|
||||
f, err := os.CreateTemp("", "nexus-test-*.db")
|
||||
if err != nil {
|
||||
t.Fatalf("create temp db: %v", err)
|
||||
}
|
||||
f.Close()
|
||||
|
||||
t.Cleanup(func() { os.Remove(f.Name()) })
|
||||
|
||||
store, err := db.Open(f.Name())
|
||||
if err != nil {
|
||||
t.Fatalf("db.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { store.Close() })
|
||||
|
||||
return store
|
||||
}
|
||||
|
||||
func TestUpsertAndListHost(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
|
||||
original := db.Host{
|
||||
IP: "192.168.0.10",
|
||||
MAC: "aa:bb:cc:dd:ee:ff",
|
||||
Vendor: "Acme Corp",
|
||||
Site: "LUD",
|
||||
FirstSeen: now,
|
||||
LastSeen: now,
|
||||
}
|
||||
|
||||
if err := store.UpsertHost(original); err != nil {
|
||||
t.Fatalf("UpsertHost (insert): %v", err)
|
||||
}
|
||||
|
||||
hosts, err := store.ListHosts("")
|
||||
if err != nil {
|
||||
t.Fatalf("ListHosts: %v", err)
|
||||
}
|
||||
if len(hosts) != 1 {
|
||||
t.Fatalf("expected 1 host, got %d", len(hosts))
|
||||
}
|
||||
|
||||
h := hosts[0]
|
||||
if h.IP != original.IP {
|
||||
t.Errorf("IP: got %q, want %q", h.IP, original.IP)
|
||||
}
|
||||
if h.MAC != original.MAC {
|
||||
t.Errorf("MAC: got %q, want %q", h.MAC, original.MAC)
|
||||
}
|
||||
if h.Status != "online" {
|
||||
t.Errorf("Status: got %q, want %q", h.Status, "online")
|
||||
}
|
||||
|
||||
// Update the same IP — last_seen and MAC should change, first_seen should not.
|
||||
updated := original
|
||||
updated.MAC = "11:22:33:44:55:66"
|
||||
updated.LastSeen = now.Add(time.Minute)
|
||||
|
||||
if err := store.UpsertHost(updated); err != nil {
|
||||
t.Fatalf("UpsertHost (update): %v", err)
|
||||
}
|
||||
|
||||
hosts, err = store.ListHosts("")
|
||||
if err != nil {
|
||||
t.Fatalf("ListHosts after update: %v", err)
|
||||
}
|
||||
if len(hosts) != 1 {
|
||||
t.Fatalf("expected 1 host after upsert, got %d", len(hosts))
|
||||
}
|
||||
if hosts[0].MAC != "11:22:33:44:55:66" {
|
||||
t.Errorf("MAC after update: got %q, want %q", hosts[0].MAC, "11:22:33:44:55:66")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListHostsFilterBySite(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
now := time.Now().UTC()
|
||||
|
||||
hosts := []db.Host{
|
||||
{IP: "10.0.0.1", MAC: "aa:aa:aa:aa:aa:01", Site: "LUD", FirstSeen: now, LastSeen: now},
|
||||
{IP: "10.0.0.2", MAC: "aa:aa:aa:aa:aa:02", Site: "LUD", FirstSeen: now, LastSeen: now},
|
||||
{IP: "10.0.1.1", MAC: "aa:aa:aa:aa:bb:01", Site: "BAR", FirstSeen: now, LastSeen: now},
|
||||
}
|
||||
|
||||
for _, h := range hosts {
|
||||
if err := store.UpsertHost(h); err != nil {
|
||||
t.Fatalf("UpsertHost %s: %v", h.IP, err)
|
||||
}
|
||||
}
|
||||
|
||||
lud, err := store.ListHosts("LUD")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(lud) != 2 {
|
||||
t.Errorf("LUD: expected 2, got %d", len(lud))
|
||||
}
|
||||
|
||||
bar, err := store.ListHosts("BAR")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(bar) != 1 {
|
||||
t.Errorf("BAR: expected 1, got %d", len(bar))
|
||||
}
|
||||
|
||||
all, err := store.ListHosts("")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(all) != 3 {
|
||||
t.Errorf("all: expected 3, got %d", len(all))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountHosts(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
now := time.Now().UTC()
|
||||
|
||||
n, err := store.CountHosts()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Errorf("expected 0 initially, got %d", n)
|
||||
}
|
||||
|
||||
for i := range 5 {
|
||||
err := store.UpsertHost(db.Host{
|
||||
IP: fmt.Sprintf("10.0.0.%d", i+1),
|
||||
MAC: fmt.Sprintf("aa:bb:cc:dd:ee:%02x", i),
|
||||
Site: "LUD",
|
||||
FirstSeen: now,
|
||||
LastSeen: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertHost %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
n, err = store.CountHosts()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 5 {
|
||||
t.Errorf("expected 5, got %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanRunLifecycle(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
|
||||
id, err := store.BeginScan("arp_discovery")
|
||||
if err != nil {
|
||||
t.Fatalf("BeginScan: %v", err)
|
||||
}
|
||||
if id == 0 {
|
||||
t.Error("expected non-zero scan ID")
|
||||
}
|
||||
|
||||
if err := store.EndScan(id, nil); err != nil {
|
||||
t.Fatalf("EndScan (ok): %v", err)
|
||||
}
|
||||
|
||||
last, err := store.LastScanTime("arp_discovery")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if last.IsZero() {
|
||||
t.Error("expected non-zero last scan time after successful run")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHostsInPrefix validates the subnet host enumeration logic.
|
||||
// This test lives here because hostsInPrefix is package-internal;
|
||||
// in a real scenario you would export it for testing or white-box test it.
|
||||
func TestHostsInPrefixCount(t *testing.T) {
|
||||
cases := []struct {
|
||||
cidr string
|
||||
count int
|
||||
}{
|
||||
{"192.168.0.0/24", 254}, // .1 – .254
|
||||
{"10.0.0.0/30", 2}, // .1 and .2 only
|
||||
{"10.0.0.0/29", 6}, // .1 – .6
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.cidr, func(t *testing.T) {
|
||||
prefix, err := netip.ParsePrefix(tc.cidr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := hostsInPrefix(prefix.Masked())
|
||||
if len(got) != tc.count {
|
||||
t.Errorf("cidr %s: got %d hosts, want %d", tc.cidr, len(got), tc.count)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// hostsInPrefix is a copy of the unexported function for white-box testing.
|
||||
func hostsInPrefix(prefix netip.Prefix) []netip.Addr {
|
||||
var addrs []netip.Addr
|
||||
addr := prefix.Masked().Addr().Next()
|
||||
for prefix.Contains(addr) {
|
||||
next := addr.Next()
|
||||
if !prefix.Contains(next) {
|
||||
break
|
||||
}
|
||||
addrs = append(addrs, addr)
|
||||
addr = next
|
||||
}
|
||||
return addrs
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
// Package dnsreverse implements the dns_reverse module.
|
||||
// It enriches already-known hosts by performing reverse-DNS lookups
|
||||
// for every host that has no hostname recorded yet.
|
||||
package dnsreverse
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cereda-systems/nexus-scanner/internal/db"
|
||||
)
|
||||
|
||||
// Module enriches hosts in the database with reverse-DNS hostnames.
|
||||
type Module struct {
|
||||
site string
|
||||
store *db.Store
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// New returns a new dns_reverse module.
|
||||
func New(site string, store *db.Store) *Module {
|
||||
return &Module{
|
||||
site: site,
|
||||
store: store,
|
||||
log: slog.With("module", "dns_reverse"),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Module) Name() string { return "dns_reverse" }
|
||||
func (m *Module) Interval() time.Duration { return 10 * time.Minute }
|
||||
|
||||
// Run looks up hostnames for all hosts that have none recorded yet.
|
||||
func (m *Module) Run(ctx context.Context) error {
|
||||
scanID, err := m.store.BeginScan(m.Name())
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin scan: %w", err)
|
||||
}
|
||||
|
||||
scanErr := m.scan(ctx)
|
||||
if endErr := m.store.EndScan(scanID, scanErr); endErr != nil {
|
||||
m.log.Error("record scan end", "err", endErr)
|
||||
}
|
||||
return scanErr
|
||||
}
|
||||
|
||||
func (m *Module) scan(ctx context.Context) error {
|
||||
hosts, err := m.store.ListHosts("")
|
||||
if err != nil {
|
||||
return fmt.Errorf("list hosts: %w", err)
|
||||
}
|
||||
|
||||
resolved := 0
|
||||
for _, h := range hosts {
|
||||
// Skip hosts that already have a hostname.
|
||||
if h.Hostname != "" {
|
||||
continue
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
names, err := net.DefaultResolver.LookupAddr(ctx, h.IP)
|
||||
if err != nil {
|
||||
// A lookup failure is not fatal — continue with the next host.
|
||||
m.log.Debug("reverse lookup failed", "ip", h.IP, "err", err)
|
||||
continue
|
||||
}
|
||||
if len(names) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// LookupAddr returns FQDNs with a trailing dot — strip it.
|
||||
hostname := strings.TrimSuffix(names[0], ".")
|
||||
|
||||
h.Hostname = hostname
|
||||
if err := m.store.UpsertHost(h); err != nil {
|
||||
m.log.Error("upsert host", "ip", h.IP, "err", err)
|
||||
continue
|
||||
}
|
||||
|
||||
m.log.Info("hostname resolved", "ip", h.IP, "hostname", hostname)
|
||||
resolved++
|
||||
}
|
||||
|
||||
m.log.Info("scan complete", "checked", len(hosts), "resolved", resolved)
|
||||
return nil
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
// Package macvendor implements the mac_vendor module.
|
||||
// It maps the OUI (first 3 bytes) of every known host's MAC address to a
|
||||
// human-readable vendor name and persists the result in the database.
|
||||
// The mapping is based on a built-in table; no external files are required.
|
||||
package macvendor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cereda-systems/nexus-scanner/internal/db"
|
||||
)
|
||||
|
||||
// ouiTable maps lowercase OUI prefixes (aa:bb:cc) to vendor names.
|
||||
// Broadcast / multicast entries are intentionally kept with an empty string
|
||||
// so they are recognised but not written to the database.
|
||||
var ouiTable = map[string]string{
|
||||
"00:0c:29": "VMware",
|
||||
"00:50:56": "VMware",
|
||||
"bc:24:11": "Proxmox/Ceph",
|
||||
"90:1b:0e": "Supermicro",
|
||||
"90:09:d0": "Synology",
|
||||
"00:17:c8": "Hewlett-Packard",
|
||||
"00:be:43": "Ubiquiti",
|
||||
"e4:43:4b": "Ubiquiti",
|
||||
"24:6a:0e": "Ubiquiti",
|
||||
"40:86:cb": "Intel",
|
||||
"68:c6:ac": "Intel",
|
||||
"1c:af:4a": "Dell",
|
||||
"50:81:40": "Dell",
|
||||
"4c:5f:70": "Lenovo",
|
||||
"dc:58:bc": "Apple",
|
||||
"c8:4b:d6": "Kyocera",
|
||||
"38:d5:7a": "Samsung",
|
||||
"00:04:a5": "Barco",
|
||||
"7c:5a:1c": "LANCOM",
|
||||
"00:0a:b3": "Cisco",
|
||||
"00:e0:67": "Aten",
|
||||
"ff:ff:ff": "", // broadcast — leave vendor empty
|
||||
}
|
||||
|
||||
// LookupVendor returns the vendor name for a MAC address.
|
||||
// The MAC must be in the format aa:bb:cc:dd:ee:ff (colon-separated).
|
||||
// An empty string is returned when no match is found.
|
||||
func LookupVendor(mac string) string {
|
||||
if len(mac) < 8 {
|
||||
return ""
|
||||
}
|
||||
oui := strings.ToLower(mac[:8])
|
||||
return ouiTable[oui]
|
||||
}
|
||||
|
||||
// Module enriches hosts in the database with OUI-based vendor names.
|
||||
type Module struct {
|
||||
site string
|
||||
store *db.Store
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// New returns a new mac_vendor module.
|
||||
func New(site string, store *db.Store) *Module {
|
||||
return &Module{
|
||||
site: site,
|
||||
store: store,
|
||||
log: slog.With("module", "mac_vendor"),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Module) Name() string { return "mac_vendor" }
|
||||
|
||||
// Interval returns 0, which causes the scheduler to run this module once at
|
||||
// startup and never repeat it automatically.
|
||||
func (m *Module) Interval() time.Duration { return 0 }
|
||||
|
||||
// Run assigns vendor names to all hosts that have none recorded yet.
|
||||
func (m *Module) Run(ctx context.Context) error {
|
||||
scanID, err := m.store.BeginScan(m.Name())
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin scan: %w", err)
|
||||
}
|
||||
|
||||
scanErr := m.scan(ctx)
|
||||
if endErr := m.store.EndScan(scanID, scanErr); endErr != nil {
|
||||
m.log.Error("record scan end", "err", endErr)
|
||||
}
|
||||
return scanErr
|
||||
}
|
||||
|
||||
func (m *Module) scan(ctx context.Context) error {
|
||||
hosts, err := m.store.ListHosts("")
|
||||
if err != nil {
|
||||
return fmt.Errorf("list hosts: %w", err)
|
||||
}
|
||||
|
||||
enriched := 0
|
||||
for _, h := range hosts {
|
||||
// Skip hosts that already have a vendor assigned.
|
||||
if h.Vendor != "" {
|
||||
continue
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
vendor := LookupVendor(h.MAC)
|
||||
if vendor == "" {
|
||||
// No entry in our table — leave vendor empty.
|
||||
continue
|
||||
}
|
||||
|
||||
h.Vendor = vendor
|
||||
if err := m.store.UpsertHost(h); err != nil {
|
||||
m.log.Error("upsert host", "ip", h.IP, "err", err)
|
||||
continue
|
||||
}
|
||||
|
||||
m.log.Info("vendor resolved", "ip", h.IP, "mac", h.MAC, "vendor", vendor)
|
||||
enriched++
|
||||
}
|
||||
|
||||
m.log.Info("scan complete", "checked", len(hosts), "enriched", enriched)
|
||||
return nil
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
package macvendor
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestLookupVendor(t *testing.T) {
|
||||
tests := []struct {
|
||||
mac string
|
||||
want string
|
||||
}{
|
||||
// Known OUIs from the built-in table.
|
||||
{"00:0c:29:1a:2b:3c", "VMware"},
|
||||
{"00:50:56:ab:cd:ef", "VMware"},
|
||||
{"bc:24:11:00:00:01", "Proxmox/Ceph"},
|
||||
{"90:1b:0e:ff:ee:dd", "Supermicro"},
|
||||
{"90:09:d0:11:22:33", "Synology"},
|
||||
{"00:17:c8:44:55:66", "Hewlett-Packard"},
|
||||
{"00:be:43:77:88:99", "Ubiquiti"},
|
||||
{"e4:43:4b:aa:bb:cc", "Ubiquiti"},
|
||||
{"24:6a:0e:dd:ee:ff", "Ubiquiti"},
|
||||
{"40:86:cb:00:11:22", "Intel"},
|
||||
{"68:c6:ac:33:44:55", "Intel"},
|
||||
{"1c:af:4a:66:77:88", "Dell"},
|
||||
{"50:81:40:99:aa:bb", "Dell"},
|
||||
{"4c:5f:70:cc:dd:ee", "Lenovo"},
|
||||
{"dc:58:bc:ff:00:11", "Apple"},
|
||||
{"c8:4b:d6:22:33:44", "Kyocera"},
|
||||
{"38:d5:7a:55:66:77", "Samsung"},
|
||||
{"00:04:a5:88:99:aa", "Barco"},
|
||||
{"7c:5a:1c:bb:cc:dd", "LANCOM"},
|
||||
{"00:0a:b3:ee:ff:00", "Cisco"},
|
||||
{"00:e0:67:11:22:33", "Aten"},
|
||||
|
||||
// Broadcast — vendor must be empty.
|
||||
{"ff:ff:ff:ff:ff:ff", ""},
|
||||
|
||||
// Unknown OUI — no vendor.
|
||||
{"de:ad:be:ef:00:01", ""},
|
||||
|
||||
// MAC shorter than 8 characters — must not panic.
|
||||
{"aa:bb", ""},
|
||||
{"", ""},
|
||||
|
||||
// Upper-case input — must be normalised.
|
||||
{"00:0C:29:1A:2B:3C", "VMware"},
|
||||
{"BC:24:11:FF:EE:DD", "Proxmox/Ceph"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
got := LookupVendor(tc.mac)
|
||||
if got != tc.want {
|
||||
t.Errorf("LookupVendor(%q) = %q, want %q", tc.mac, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
// Package nexusreporter pushes newly discovered or changed assets to IT Nexus.
|
||||
package nexusreporter
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/cereda-systems/nexus-scanner/internal/config"
|
||||
"github.com/cereda-systems/nexus-scanner/internal/db"
|
||||
)
|
||||
|
||||
const modName = "nexus_reporter"
|
||||
|
||||
type Module struct {
|
||||
site string
|
||||
cfg config.ReporterConfig
|
||||
nexus config.Config // full config for URL + APIKey
|
||||
store *db.Store
|
||||
client *http.Client
|
||||
// last hash per IP to detect changes
|
||||
lastHash map[string][32]byte
|
||||
}
|
||||
|
||||
// New creates a new IT Nexus reporter module.
|
||||
func New(site string, cfg config.ReporterConfig, nexusCfg config.Config, store *db.Store) *Module {
|
||||
return &Module{
|
||||
site: site,
|
||||
cfg: cfg,
|
||||
nexus: nexusCfg,
|
||||
store: store,
|
||||
client: &http.Client{Timeout: 15 * time.Second},
|
||||
lastHash: make(map[string][32]byte),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Module) Name() string { return modName }
|
||||
func (m *Module) Interval() time.Duration { return m.cfg.Interval }
|
||||
|
||||
type assetPayload struct {
|
||||
Site string `json:"site"`
|
||||
Version string `json:"scanner_version"`
|
||||
ReportedAt time.Time `json:"reported_at"`
|
||||
Assets []assetItem `json:"assets"`
|
||||
}
|
||||
|
||||
type assetItem struct {
|
||||
IP string `json:"ip"`
|
||||
MAC string `json:"mac"`
|
||||
Hostname string `json:"hostname"`
|
||||
Vendor string `json:"vendor"`
|
||||
Status string `json:"status"`
|
||||
FirstSeen time.Time `json:"first_seen"`
|
||||
LastSeen time.Time `json:"last_seen"`
|
||||
}
|
||||
|
||||
// Run reports changed/new hosts to IT Nexus.
|
||||
func (m *Module) Run(ctx context.Context) error {
|
||||
if m.nexus.Nexus.URL == "" || m.nexus.Nexus.APIKey == "" {
|
||||
slog.Debug("nexus reporter skipped — URL or APIKey not configured")
|
||||
return nil
|
||||
}
|
||||
|
||||
hosts, err := m.store.ListHosts("")
|
||||
if err != nil {
|
||||
return fmt.Errorf("list hosts: %w", err)
|
||||
}
|
||||
|
||||
var changed []assetItem
|
||||
for _, h := range hosts {
|
||||
hash := hostHash(h)
|
||||
if prev, seen := m.lastHash[h.IP]; seen && prev == hash {
|
||||
continue // unchanged
|
||||
}
|
||||
m.lastHash[h.IP] = hash
|
||||
changed = append(changed, assetItem{
|
||||
IP: h.IP,
|
||||
MAC: h.MAC,
|
||||
Hostname: h.Hostname,
|
||||
Vendor: h.Vendor,
|
||||
Status: h.Status,
|
||||
FirstSeen: h.FirstSeen,
|
||||
LastSeen: h.LastSeen,
|
||||
})
|
||||
}
|
||||
|
||||
if len(changed) == 0 {
|
||||
slog.Debug("nexus reporter: no changes")
|
||||
return nil
|
||||
}
|
||||
|
||||
payload := assetPayload{
|
||||
Site: m.site,
|
||||
Version: "1.0",
|
||||
ReportedAt: time.Now(),
|
||||
Assets: changed,
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(payload)
|
||||
url := m.nexus.Nexus.URL + "/api/scanner/assets"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Scanner-Key", m.nexus.Nexus.APIKey)
|
||||
|
||||
resp, err := m.client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("post to nexus: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("nexus returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
slog.Info("nexus reporter: assets reported", "count", len(changed), "url", url)
|
||||
return nil
|
||||
}
|
||||
|
||||
func hostHash(h db.Host) [32]byte {
|
||||
s := h.IP + "|" + h.MAC + "|" + h.Hostname + "|" + h.Vendor + "|" + h.Status
|
||||
return sha256.Sum256([]byte(s))
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
// Package portscan performs concurrent TCP port scanning on all online hosts.
|
||||
package portscan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/cereda-systems/nexus-scanner/internal/config"
|
||||
"github.com/cereda-systems/nexus-scanner/internal/db"
|
||||
)
|
||||
|
||||
const modName = "port_scan"
|
||||
|
||||
// Common ports to scan when none are configured.
|
||||
var defaultPorts = []int{21, 22, 23, 25, 53, 80, 110, 135, 139, 143, 443, 445, 3389, 5985, 8080, 8443, 9100}
|
||||
|
||||
// Module scans TCP ports on all online hosts.
|
||||
type Module struct {
|
||||
site string
|
||||
cfg config.PortScanConfig
|
||||
store *db.Store
|
||||
}
|
||||
|
||||
// New creates a new port scan module.
|
||||
func New(site string, cfg config.PortScanConfig, store *db.Store) *Module {
|
||||
return &Module{site: site, cfg: cfg, store: store}
|
||||
}
|
||||
|
||||
func (m *Module) Name() string { return modName }
|
||||
func (m *Module) Interval() time.Duration { return m.cfg.Interval }
|
||||
|
||||
// Run scans all online hosts and stores their open ports.
|
||||
func (m *Module) Run(ctx context.Context) error {
|
||||
hosts, err := m.store.ListHosts("")
|
||||
if err != nil {
|
||||
return fmt.Errorf("list hosts: %w", err)
|
||||
}
|
||||
|
||||
ports := m.cfg.Ports
|
||||
if len(ports) == 0 {
|
||||
ports = defaultPorts
|
||||
}
|
||||
timeout := m.cfg.Timeout
|
||||
if timeout == 0 {
|
||||
timeout = 500 * time.Millisecond
|
||||
}
|
||||
|
||||
scanID, err := m.store.BeginScan(modName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin scan: %w", err)
|
||||
}
|
||||
|
||||
found := 0
|
||||
for _, h := range hosts {
|
||||
if h.Status != "online" {
|
||||
continue
|
||||
}
|
||||
open := scanHost(ctx, h.IP, ports, timeout)
|
||||
sort.Ints(open)
|
||||
if err := m.store.UpsertHostPorts(h.IP, open); err != nil {
|
||||
slog.Error("save host ports", "ip", h.IP, "err", err)
|
||||
}
|
||||
if len(open) > 0 {
|
||||
slog.Debug("port scan", "ip", h.IP, "open", open)
|
||||
found++
|
||||
}
|
||||
}
|
||||
|
||||
_ = m.store.EndScan(scanID, nil)
|
||||
slog.Info("port scan complete", "hosts_with_open_ports", found, "total_hosts", len(hosts))
|
||||
return nil
|
||||
}
|
||||
|
||||
// scanHost tries all ports concurrently (max 30 goroutines) and returns open ones.
|
||||
func scanHost(ctx context.Context, ip string, ports []int, timeout time.Duration) []int {
|
||||
var mu sync.Mutex
|
||||
var open []int
|
||||
var wg sync.WaitGroup
|
||||
sem := make(chan struct{}, 30)
|
||||
|
||||
for _, port := range ports {
|
||||
wg.Add(1)
|
||||
go func(p int) {
|
||||
defer wg.Done()
|
||||
sem <- struct{}{}
|
||||
defer func() { <-sem }()
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", ip, p)
|
||||
d := net.Dialer{Timeout: timeout}
|
||||
conn, err := d.DialContext(ctx, "tcp", addr)
|
||||
if err == nil {
|
||||
conn.Close()
|
||||
mu.Lock()
|
||||
open = append(open, p)
|
||||
mu.Unlock()
|
||||
}
|
||||
}(port)
|
||||
}
|
||||
wg.Wait()
|
||||
return open
|
||||
}
|
||||
@@ -1,298 +0,0 @@
|
||||
// Package sitemon checks the reachability of configured targets (ping, http, tcp).
|
||||
// Each scanner instance monitors from its own site perspective, giving you
|
||||
// per-site uptime visibility across all your locations.
|
||||
package sitemon
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/smtp"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cereda-systems/nexus-scanner/internal/config"
|
||||
"github.com/cereda-systems/nexus-scanner/internal/db"
|
||||
)
|
||||
|
||||
const modName = "site_monitoring"
|
||||
|
||||
// Module runs periodic availability checks and stores results in the DB.
|
||||
type Module struct {
|
||||
site string
|
||||
cfg config.SiteMonConfig
|
||||
alertCfg config.AlertConfig
|
||||
nexusCfg config.Config
|
||||
store *db.Store
|
||||
client *http.Client
|
||||
alertHTTP *http.Client
|
||||
// track previous state per check ID to detect transitions
|
||||
prevState map[int64]string
|
||||
}
|
||||
|
||||
// New creates a new site monitoring module.
|
||||
func New(site string, cfg config.SiteMonConfig, alertCfg config.AlertConfig, nexusCfg config.Config, store *db.Store) *Module {
|
||||
return &Module{
|
||||
site: site,
|
||||
cfg: cfg,
|
||||
alertCfg: alertCfg,
|
||||
nexusCfg: nexusCfg,
|
||||
store: store,
|
||||
prevState: make(map[int64]string),
|
||||
alertHTTP: &http.Client{Timeout: 10 * time.Second},
|
||||
client: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 5 {
|
||||
return fmt.Errorf("too many redirects")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Module) Name() string { return modName }
|
||||
func (m *Module) Interval() time.Duration { return m.cfg.Interval }
|
||||
|
||||
// Run executes all enabled checks once and stores results.
|
||||
func (m *Module) Run(ctx context.Context) error {
|
||||
checks, err := m.store.ListMonitorChecks()
|
||||
if err != nil {
|
||||
return fmt.Errorf("list checks: %w", err)
|
||||
}
|
||||
if len(checks) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
scanID, err := m.store.BeginScan(modName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin scan: %w", err)
|
||||
}
|
||||
|
||||
for _, check := range checks {
|
||||
if !check.Enabled {
|
||||
continue
|
||||
}
|
||||
result := m.runCheck(ctx, check)
|
||||
if err := m.store.InsertMonitorResult(result); err != nil {
|
||||
slog.Error("insert monitor result", "check", check.Name, "err", err)
|
||||
}
|
||||
slog.Debug("monitor check done",
|
||||
"name", check.Name, "status", result.Status, "latency_ms", result.LatencyMS)
|
||||
|
||||
// Detect state transitions and fire alerts.
|
||||
if prev, seen := m.prevState[check.ID]; seen && prev != result.Status {
|
||||
go m.sendAlert(check, result)
|
||||
}
|
||||
m.prevState[check.ID] = result.Status
|
||||
}
|
||||
|
||||
_ = m.store.EndScan(scanID, nil)
|
||||
_ = m.store.CleanupMonitorResults(100)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Module) runCheck(ctx context.Context, check db.MonitorCheck) db.MonitorResult {
|
||||
result := db.MonitorResult{
|
||||
CheckID: check.ID,
|
||||
CheckedAt: time.Now(),
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
var checkErr error
|
||||
|
||||
switch check.Type {
|
||||
case "ping":
|
||||
checkErr = doPing(ctx, check.Target)
|
||||
case "http", "https":
|
||||
checkErr = doHTTP(ctx, m.client, check.Target)
|
||||
case "tcp":
|
||||
checkErr = doTCP(ctx, check.Target)
|
||||
default:
|
||||
checkErr = fmt.Errorf("unknown type: %s", check.Type)
|
||||
}
|
||||
|
||||
result.LatencyMS = int(time.Since(start).Milliseconds())
|
||||
if checkErr != nil {
|
||||
result.Status = "offline"
|
||||
result.Error = checkErr.Error()
|
||||
} else {
|
||||
result.Status = "online"
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func doPing(ctx context.Context, target string) error {
|
||||
var args []string
|
||||
if runtime.GOOS == "windows" {
|
||||
args = []string{"-n", "1", "-w", "2000", target}
|
||||
} else {
|
||||
args = []string{"-c", "1", "-W", "2", target}
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, "ping", args...)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return fmt.Errorf("ping: %w", err)
|
||||
}
|
||||
lower := strings.ToLower(string(out))
|
||||
if strings.Contains(lower, "unreachable") ||
|
||||
strings.Contains(lower, "timed out") ||
|
||||
strings.Contains(lower, "100% loss") ||
|
||||
strings.Contains(lower, "100% packet loss") {
|
||||
return fmt.Errorf("host unreachable")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func doHTTP(ctx context.Context, client *http.Client, target string) error {
|
||||
if !strings.HasPrefix(target, "http://") && !strings.HasPrefix(target, "https://") {
|
||||
target = "https://" + target
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("User-Agent", "NexusScanner/1.0")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode >= 500 {
|
||||
return fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func doTCP(ctx context.Context, target string) error {
|
||||
d := net.Dialer{Timeout: 5 * time.Second}
|
||||
conn, err := d.DialContext(ctx, "tcp", target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
conn.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
/* ── Alerts ───────────────────────────────────────────────────────── */
|
||||
|
||||
func (m *Module) sendAlert(check db.MonitorCheck, result db.MonitorResult) {
|
||||
emoji := "🔴"
|
||||
word := "OFFLINE"
|
||||
if result.Status == "online" {
|
||||
emoji = "✅"
|
||||
word = "WIEDER ONLINE"
|
||||
}
|
||||
subject := fmt.Sprintf("[Nexus Scanner %s] %s %s: %s", m.site, emoji, word, check.Name)
|
||||
body := fmt.Sprintf(
|
||||
"Standort: %s\nCheck: %s (%s)\nZiel: %s\nStatus: %s\nZeit: %s\n",
|
||||
m.site, check.Name, check.Type, check.Target,
|
||||
strings.ToUpper(result.Status),
|
||||
result.CheckedAt.Format("02.01.2006 15:04:05"),
|
||||
)
|
||||
if result.Error != "" {
|
||||
body += "Fehler: " + result.Error + "\n"
|
||||
}
|
||||
|
||||
if m.alertCfg.SMTP.Enabled {
|
||||
if err := m.sendMail(subject, body); err != nil {
|
||||
slog.Error("alert mail failed", "err", err)
|
||||
} else {
|
||||
slog.Info("alert mail sent", "check", check.Name, "status", result.Status)
|
||||
}
|
||||
}
|
||||
|
||||
if m.alertCfg.NexusEnabled && m.nexusCfg.Nexus.URL != "" {
|
||||
if err := m.sendNexusAlert(check, result, subject, body); err != nil {
|
||||
slog.Error("nexus alert failed", "err", err)
|
||||
} else {
|
||||
slog.Info("nexus alert sent", "check", check.Name, "status", result.Status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Module) sendMail(subject, body string) error {
|
||||
sc := m.alertCfg.SMTP
|
||||
addr := fmt.Sprintf("%s:%d", sc.Host, sc.Port)
|
||||
msg := []byte("From: " + sc.From + "\r\n" +
|
||||
"To: " + sc.To + "\r\n" +
|
||||
"Subject: " + subject + "\r\n" +
|
||||
"Content-Type: text/plain; charset=UTF-8\r\n\r\n" +
|
||||
body)
|
||||
|
||||
var auth smtp.Auth
|
||||
if sc.Username != "" {
|
||||
auth = smtp.PlainAuth("", sc.Username, sc.Password, sc.Host)
|
||||
}
|
||||
|
||||
// Try STARTTLS first, fall back to plain.
|
||||
tlsCfg := &tls.Config{ServerName: sc.Host, InsecureSkipVerify: false}
|
||||
c, err := smtp.Dial(addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dial smtp: %w", err)
|
||||
}
|
||||
defer c.Close()
|
||||
if ok, _ := c.Extension("STARTTLS"); ok {
|
||||
if err := c.StartTLS(tlsCfg); err != nil {
|
||||
return fmt.Errorf("starttls: %w", err)
|
||||
}
|
||||
}
|
||||
if auth != nil {
|
||||
if err := c.Auth(auth); err != nil {
|
||||
return fmt.Errorf("smtp auth: %w", err)
|
||||
}
|
||||
}
|
||||
if err := c.Mail(sc.From); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.Rcpt(sc.To); err != nil {
|
||||
return err
|
||||
}
|
||||
wc, err := c.Data()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer wc.Close()
|
||||
_, err = wc.Write(msg)
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *Module) sendNexusAlert(check db.MonitorCheck, result db.MonitorResult, subject, body string) error {
|
||||
payload := map[string]any{
|
||||
"site": m.site,
|
||||
"check": check.Name,
|
||||
"type": check.Type,
|
||||
"target": check.Target,
|
||||
"status": result.Status,
|
||||
"latency": result.LatencyMS,
|
||||
"error": result.Error,
|
||||
"timestamp": result.CheckedAt,
|
||||
"subject": subject,
|
||||
"body": body,
|
||||
}
|
||||
b, _ := json.Marshal(payload)
|
||||
url := m.nexusCfg.Nexus.URL + "/api/scanner/alert"
|
||||
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Scanner-Key", m.nexusCfg.Nexus.APIKey)
|
||||
resp, err := m.alertHTTP.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("nexus returned %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,334 +0,0 @@
|
||||
// Package snmpmod polls SNMP targets for device information.
|
||||
// Supports generic devices, printers (toner/pages) and switches.
|
||||
package snmpmod
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gosnmp/gosnmp"
|
||||
|
||||
"github.com/cereda-systems/nexus-scanner/internal/config"
|
||||
"github.com/cereda-systems/nexus-scanner/internal/db"
|
||||
)
|
||||
|
||||
const modName = "snmp"
|
||||
|
||||
// OIDs queried for every device.
|
||||
var baseOIDs = map[string]string{
|
||||
"sysDescr": "1.3.6.1.2.1.1.1.0",
|
||||
"sysUpTime": "1.3.6.1.2.1.1.3.0",
|
||||
"sysName": "1.3.6.1.2.1.1.5.0",
|
||||
"sysLocation": "1.3.6.1.2.1.1.6.0",
|
||||
"sysContact": "1.3.6.1.2.1.1.4.0",
|
||||
"ifNumber": "1.3.6.1.2.1.2.1.0",
|
||||
}
|
||||
|
||||
// Switch-specific scalar OIDs (queried via Get).
|
||||
var switchScalarOIDs = map[string]string{
|
||||
"cpuLoad": "1.3.6.1.2.1.25.3.3.1.2.1", // hrProcessorLoad (first CPU)
|
||||
}
|
||||
|
||||
// Printer-specific OIDs (Kyocera / most RFC 3805 printers).
|
||||
var printerOIDs = map[string]string{
|
||||
"tonerLevel": "1.3.6.1.2.1.43.11.1.1.9.1.1",
|
||||
"tonerMax": "1.3.6.1.2.1.43.11.1.1.8.1.1",
|
||||
"pageCount": "1.3.6.1.2.1.43.10.2.1.4.1.1",
|
||||
"printerStatus": "1.3.6.1.2.1.25.3.5.1.1.1",
|
||||
}
|
||||
|
||||
// Walk base OIDs for interfaces and memory.
|
||||
const (
|
||||
oidIfOperStatus = "1.3.6.1.2.1.2.2.1.8" // 1=up, 2=down
|
||||
oidIfHCInOctets = "1.3.6.1.2.1.31.1.1.1.6" // 64-bit in bytes
|
||||
oidIfHCOutOctets = "1.3.6.1.2.1.31.1.1.1.10" // 64-bit out bytes
|
||||
oidIfDescr = "1.3.6.1.2.1.2.2.1.2" // interface name
|
||||
oidHrStorageDescr = "1.3.6.1.2.1.25.2.3.1.2"
|
||||
oidHrStorageUsed = "1.3.6.1.2.1.25.2.3.1.6"
|
||||
oidHrStorageSize = "1.3.6.1.2.1.25.2.3.1.5"
|
||||
oidHrStorageAlloc = "1.3.6.1.2.1.25.2.3.1.4"
|
||||
)
|
||||
|
||||
// Module polls SNMP targets and stores results.
|
||||
type Module struct {
|
||||
site string
|
||||
cfg config.SNMPConfig
|
||||
store *db.Store
|
||||
}
|
||||
|
||||
// New creates a new SNMP module.
|
||||
func New(site string, cfg config.SNMPConfig, store *db.Store) *Module {
|
||||
return &Module{site: site, cfg: cfg, store: store}
|
||||
}
|
||||
|
||||
func (m *Module) Name() string { return modName }
|
||||
func (m *Module) Interval() time.Duration { return m.cfg.Interval }
|
||||
|
||||
// Run polls all enabled SNMP targets.
|
||||
func (m *Module) Run(ctx context.Context) error {
|
||||
targets, err := m.store.ListSNMPTargets()
|
||||
if err != nil {
|
||||
return fmt.Errorf("list snmp targets: %w", err)
|
||||
}
|
||||
if len(targets) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
scanID, err := m.store.BeginScan(modName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin scan: %w", err)
|
||||
}
|
||||
|
||||
ok := 0
|
||||
for _, t := range targets {
|
||||
if !t.Enabled {
|
||||
continue
|
||||
}
|
||||
if err := m.pollTarget(ctx, t); err != nil {
|
||||
slog.Warn("snmp poll failed", "target", t.Name, "ip", t.IP, "err", err)
|
||||
} else {
|
||||
ok++
|
||||
}
|
||||
}
|
||||
|
||||
_ = m.store.EndScan(scanID, nil)
|
||||
slog.Info("snmp poll complete", "ok", ok, "total", len(targets))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Module) pollTarget(ctx context.Context, t db.SNMPTarget) error {
|
||||
community := t.Community
|
||||
if community == "" {
|
||||
community = m.cfg.Community
|
||||
}
|
||||
|
||||
gs := &gosnmp.GoSNMP{
|
||||
Target: t.IP,
|
||||
Port: 161,
|
||||
Community: community,
|
||||
Version: snmpVersion(t.Version),
|
||||
Timeout: 10 * time.Second,
|
||||
Retries: 1,
|
||||
MaxOids: 60,
|
||||
}
|
||||
if err := gs.Connect(); err != nil {
|
||||
return fmt.Errorf("connect: %w", err)
|
||||
}
|
||||
defer gs.Conn.Close()
|
||||
|
||||
now := time.Now()
|
||||
|
||||
// --- Base + type-specific scalar GET ---
|
||||
oids := make(map[string]string)
|
||||
for k, v := range baseOIDs {
|
||||
oids[k] = v
|
||||
}
|
||||
switch t.Type {
|
||||
case "printer":
|
||||
for k, v := range printerOIDs {
|
||||
oids[k] = v
|
||||
}
|
||||
case "switch":
|
||||
for k, v := range switchScalarOIDs {
|
||||
oids[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
oidList := make([]string, 0, len(oids))
|
||||
reverseMap := make(map[string]string)
|
||||
for name, oid := range oids {
|
||||
oidList = append(oidList, oid)
|
||||
reverseMap[oid] = name
|
||||
}
|
||||
|
||||
result, err := gs.Get(oidList)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get: %w", err)
|
||||
}
|
||||
|
||||
for _, pdu := range result.Variables {
|
||||
name := resolveOID(pdu.Name, reverseMap)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
value := pduToString(pdu)
|
||||
if value == "" || value == "<nil>" || strings.HasPrefix(value, "<nil>") {
|
||||
continue
|
||||
}
|
||||
_ = m.store.UpsertSNMPResult(db.SNMPResult{
|
||||
TargetID: t.ID,
|
||||
OIDName: name,
|
||||
Value: value,
|
||||
ScannedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
// --- Switch: walk interface table + memory ---
|
||||
if t.Type == "switch" {
|
||||
m.walkSwitch(gs, t, now)
|
||||
}
|
||||
|
||||
slog.Debug("snmp polled", "target", t.Name, "ip", t.IP, "type", t.Type)
|
||||
return nil
|
||||
}
|
||||
|
||||
// walkSwitch collects interface stats and memory for switch-type targets.
|
||||
func (m *Module) walkSwitch(gs *gosnmp.GoSNMP, t db.SNMPTarget, now time.Time) {
|
||||
// Interface status walk → portsUp / portsTotal
|
||||
var portsUp, portsTotal int
|
||||
var trafficIn, trafficOut uint64
|
||||
|
||||
ifStatus := walkOID(gs, oidIfOperStatus)
|
||||
for _, v := range ifStatus {
|
||||
portsTotal++
|
||||
if v == "1" {
|
||||
portsUp++
|
||||
}
|
||||
}
|
||||
|
||||
// Traffic counters (64-bit)
|
||||
for _, v := range walkOID(gs, oidIfHCInOctets) {
|
||||
n, _ := strconv.ParseUint(v, 10, 64)
|
||||
trafficIn += n
|
||||
}
|
||||
for _, v := range walkOID(gs, oidIfHCOutOctets) {
|
||||
n, _ := strconv.ParseUint(v, 10, 64)
|
||||
trafficOut += n
|
||||
}
|
||||
|
||||
save := func(name, value string) {
|
||||
_ = m.store.UpsertSNMPResult(db.SNMPResult{
|
||||
TargetID: t.ID,
|
||||
OIDName: name,
|
||||
Value: value,
|
||||
ScannedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
if portsTotal > 0 {
|
||||
save("portsTotal", strconv.Itoa(portsTotal))
|
||||
save("portsUp", strconv.Itoa(portsUp))
|
||||
}
|
||||
if trafficIn > 0 {
|
||||
save("trafficIn", strconv.FormatUint(trafficIn, 10))
|
||||
}
|
||||
if trafficOut > 0 {
|
||||
save("trafficOut", strconv.FormatUint(trafficOut, 10))
|
||||
}
|
||||
|
||||
// Memory: find "Physical Memory" or "Real Memory" row
|
||||
descrByIdx := walkOID(gs, oidHrStorageDescr)
|
||||
usedByIdx := walkOID(gs, oidHrStorageUsed)
|
||||
sizeByIdx := walkOID(gs, oidHrStorageSize)
|
||||
allocByIdx := walkOID(gs, oidHrStorageAlloc)
|
||||
|
||||
for idx, descr := range descrByIdx {
|
||||
d := strings.ToLower(descr)
|
||||
if strings.Contains(d, "physical") || strings.Contains(d, "real") || strings.Contains(d, "ram") {
|
||||
used, _ := strconv.ParseInt(usedByIdx[idx], 10, 64)
|
||||
size, _ := strconv.ParseInt(sizeByIdx[idx], 10, 64)
|
||||
alloc, _ := strconv.ParseInt(allocByIdx[idx], 10, 64)
|
||||
if alloc <= 0 {
|
||||
alloc = 1024
|
||||
}
|
||||
usedBytes := used * alloc
|
||||
totalBytes := size * alloc
|
||||
if totalBytes > 0 {
|
||||
save("memUsed", strconv.FormatInt(usedBytes, 10))
|
||||
save("memTotal", strconv.FormatInt(totalBytes, 10))
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// walkOID performs an SNMP walk and returns a map of last-OID-index → value string.
|
||||
func walkOID(gs *gosnmp.GoSNMP, baseOID string) map[string]string {
|
||||
out := make(map[string]string)
|
||||
err := gs.Walk(baseOID, func(pdu gosnmp.SnmpPDU) error {
|
||||
// Extract the index (last component of OID)
|
||||
name := strings.TrimPrefix(pdu.Name, ".")
|
||||
parts := strings.Split(name, ".")
|
||||
idx := parts[len(parts)-1]
|
||||
val := pduRawToString(pdu)
|
||||
if val != "" {
|
||||
out[idx] = val
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
slog.Debug("snmp walk failed", "oid", baseOID, "err", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func resolveOID(oidName string, reverseMap map[string]string) string {
|
||||
key := strings.TrimPrefix(oidName, ".")
|
||||
if name := reverseMap[key]; name != "" {
|
||||
return name
|
||||
}
|
||||
// Try without trailing .0
|
||||
if len(key) > 2 && key[len(key)-2:] == ".0" {
|
||||
return reverseMap[key[:len(key)-2]]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func snmpVersion(v string) gosnmp.SnmpVersion {
|
||||
switch v {
|
||||
case "v1":
|
||||
return gosnmp.Version1
|
||||
case "v3":
|
||||
return gosnmp.Version3
|
||||
default:
|
||||
return gosnmp.Version2c
|
||||
}
|
||||
}
|
||||
|
||||
// pduToString converts a PDU value to a display string (formatted).
|
||||
func pduToString(pdu gosnmp.SnmpPDU) string {
|
||||
switch pdu.Type {
|
||||
case gosnmp.OctetString:
|
||||
if b, ok := pdu.Value.([]byte); ok {
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
case gosnmp.TimeTicks:
|
||||
ticks, _ := pdu.Value.(uint32)
|
||||
d := time.Duration(ticks) * 10 * time.Millisecond
|
||||
h := int(d.Hours())
|
||||
mn := int(d.Minutes()) % 60
|
||||
return strconv.Itoa(h) + "h " + strconv.Itoa(mn) + "m"
|
||||
case gosnmp.ObjectIdentifier:
|
||||
if s, ok := pdu.Value.(string); ok {
|
||||
return s
|
||||
}
|
||||
default:
|
||||
return fmt.Sprintf("%v", pdu.Value)
|
||||
}
|
||||
return fmt.Sprintf("%v", pdu.Value)
|
||||
}
|
||||
|
||||
// pduRawToString converts a PDU value to a raw numeric/string value (for walks).
|
||||
func pduRawToString(pdu gosnmp.SnmpPDU) string {
|
||||
switch pdu.Type {
|
||||
case gosnmp.OctetString:
|
||||
if b, ok := pdu.Value.([]byte); ok {
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
case gosnmp.Integer:
|
||||
return fmt.Sprintf("%d", pdu.Value)
|
||||
case gosnmp.Counter32, gosnmp.Gauge32, gosnmp.Uinteger32:
|
||||
return fmt.Sprintf("%d", pdu.Value)
|
||||
case gosnmp.Counter64:
|
||||
return fmt.Sprintf("%d", pdu.Value)
|
||||
case gosnmp.TimeTicks:
|
||||
if t, ok := pdu.Value.(uint32); ok {
|
||||
return fmt.Sprintf("%d", t)
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%v", pdu.Value)
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
// Package sysarp implements a cross-platform ARP-table reader.
|
||||
// It runs "arp -a" (available on Windows, Linux and macOS) and
|
||||
// writes every discovered host into the shared SQLite store.
|
||||
// Unlike the arp_discovery module it does NOT send packets —
|
||||
// it reads what the OS already knows, so no raw-socket privilege is needed.
|
||||
package sysarp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cereda-systems/nexus-scanner/internal/db"
|
||||
)
|
||||
|
||||
// Module reads the OS ARP cache and persists discovered hosts.
|
||||
type Module struct {
|
||||
site string
|
||||
store *db.Store
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// New returns a new sys_arp module.
|
||||
func New(site string, store *db.Store) *Module {
|
||||
return &Module{
|
||||
site: site,
|
||||
store: store,
|
||||
log: slog.With("module", "sys_arp"),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Module) Name() string { return "sys_arp" }
|
||||
func (m *Module) Interval() time.Duration { return 2 * time.Minute }
|
||||
|
||||
// Run executes one read of the ARP cache.
|
||||
func (m *Module) Run(ctx context.Context) error {
|
||||
scanID, err := m.store.BeginScan(m.Name())
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin scan: %w", err)
|
||||
}
|
||||
|
||||
scanErr := m.scan(ctx)
|
||||
if endErr := m.store.EndScan(scanID, scanErr); endErr != nil {
|
||||
m.log.Error("record scan end", "err", endErr)
|
||||
}
|
||||
return scanErr
|
||||
}
|
||||
|
||||
func (m *Module) scan(ctx context.Context) error {
|
||||
out, err := exec.CommandContext(ctx, "arp", "-a").Output()
|
||||
if err != nil {
|
||||
return fmt.Errorf("arp -a failed: %w", err)
|
||||
}
|
||||
|
||||
entries := parseARPOutput(out)
|
||||
m.log.Info("ARP cache read", "entries", len(entries))
|
||||
|
||||
now := time.Now().UTC()
|
||||
saved := 0
|
||||
for _, e := range entries {
|
||||
if err := m.store.UpsertHost(db.Host{
|
||||
IP: e.ip,
|
||||
MAC: e.mac,
|
||||
Site: m.site,
|
||||
FirstSeen: now,
|
||||
LastSeen: now,
|
||||
}); err != nil {
|
||||
m.log.Error("upsert host", "ip", e.ip, "err", err)
|
||||
continue
|
||||
}
|
||||
m.log.Info("host found", "ip", e.ip, "mac", e.mac)
|
||||
saved++
|
||||
}
|
||||
|
||||
m.log.Info("scan complete", "found", len(entries), "saved", saved)
|
||||
return nil
|
||||
}
|
||||
|
||||
type arpEntry struct{ ip, mac string }
|
||||
|
||||
// Windows: " 192.168.0.1 aa-bb-cc-dd-ee-ff dynamic"
|
||||
var reWindows = regexp.MustCompile(`^\s+([\d.]+)\s+([0-9a-fA-F]{2}(?:-[0-9a-fA-F]{2}){5})\s+\S+`)
|
||||
|
||||
// Linux/macOS: "? (192.168.0.1) at aa:bb:cc:dd:ee:ff [ether] on eth0"
|
||||
var reUnix = regexp.MustCompile(`\(([\d.]+)\) at ([0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5})`)
|
||||
|
||||
// skipPrefixes are IPs that are multicast / broadcast — not real hosts.
|
||||
var skipPrefixes = []string{"224.", "239.", "255.", "ff02"}
|
||||
|
||||
func parseARPOutput(data []byte) []arpEntry {
|
||||
var entries []arpEntry
|
||||
seen := make(map[string]struct{})
|
||||
|
||||
scanner := bufio.NewScanner(bytes.NewReader(data))
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
var ip, mac string
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
m := reWindows.FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
ip = m[1]
|
||||
// Normalise Windows dashes to colons: aa-bb-cc → aa:bb:cc
|
||||
mac = strings.ReplaceAll(strings.ToLower(m[2]), "-", ":")
|
||||
} else {
|
||||
m := reUnix.FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
ip = m[1]
|
||||
mac = strings.ToLower(m[2])
|
||||
}
|
||||
|
||||
// Skip multicast, broadcast and already-seen IPs.
|
||||
skip := false
|
||||
for _, pfx := range skipPrefixes {
|
||||
if strings.HasPrefix(ip, pfx) || strings.HasPrefix(mac, pfx) {
|
||||
skip = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if skip {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[ip]; dup {
|
||||
continue
|
||||
}
|
||||
seen[ip] = struct{}{}
|
||||
entries = append(entries, arpEntry{ip: ip, mac: mac})
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/cereda-systems/nexus-scanner/internal/module"
|
||||
)
|
||||
|
||||
// Scheduler runs registered modules at their configured intervals.
|
||||
type Scheduler struct {
|
||||
registry *module.Registry
|
||||
}
|
||||
|
||||
// New returns a new Scheduler backed by the given registry.
|
||||
func New(registry *module.Registry) *Scheduler {
|
||||
return &Scheduler{registry: registry}
|
||||
}
|
||||
|
||||
// Run starts all modules in background goroutines and blocks until ctx is cancelled.
|
||||
func (s *Scheduler) Run(ctx context.Context) {
|
||||
for _, m := range s.registry.All() {
|
||||
go s.runModule(ctx, m)
|
||||
}
|
||||
<-ctx.Done()
|
||||
}
|
||||
|
||||
func (s *Scheduler) runModule(ctx context.Context, m module.Module) {
|
||||
log := slog.With("module", m.Name())
|
||||
|
||||
// Run immediately on startup.
|
||||
s.execute(ctx, m, log)
|
||||
|
||||
// If interval is zero, run once and stop.
|
||||
if m.Interval() == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(m.Interval())
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.execute(ctx, m, log)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) execute(ctx context.Context, m module.Module, log *slog.Logger) {
|
||||
log.Info("run started")
|
||||
start := time.Now()
|
||||
|
||||
if err := m.Run(ctx); err != nil {
|
||||
log.Error("run failed", "err", err, "elapsed", time.Since(start).Round(time.Millisecond))
|
||||
return
|
||||
}
|
||||
|
||||
log.Info("run completed", "elapsed", time.Since(start).Round(time.Millisecond))
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
package web
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed static templates
|
||||
var webFS embed.FS
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,510 +0,0 @@
|
||||
/* ── Reset ── */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
/* ── Design Tokens ── */
|
||||
:root {
|
||||
--teal: #008487;
|
||||
--teal-600: #006e71;
|
||||
--teal-700: #005a5c;
|
||||
--teal-50: #e6f3f3;
|
||||
--bg: #f5f5f7;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #fbfbfd;
|
||||
--surface-3: #f2f2f4;
|
||||
--text: #1d1d1f;
|
||||
--text-2: #515154;
|
||||
--text-3: #86868b;
|
||||
--border: rgba(0,0,0,0.08);
|
||||
--border-strong: rgba(0,0,0,0.14);
|
||||
--shadow-sm: 0 1px 2px rgba(0,0,0,0.04), 0 1px 1px rgba(0,0,0,0.03);
|
||||
--shadow-md: 0 4px 16px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04);
|
||||
--shadow-lg: 0 24px 64px -16px rgba(0,0,0,0.18), 0 6px 18px rgba(0,0,0,0.08);
|
||||
--success: #34a853;
|
||||
--warning: #e8a23a;
|
||||
--danger: #e5484d;
|
||||
--info: #2f7df1;
|
||||
--r-sm: 8px;
|
||||
--r-md: 12px;
|
||||
--r-lg: 16px;
|
||||
--r-xl: 20px;
|
||||
--sidebar-w: 232px;
|
||||
--topbar-h: 52px;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--bg: #000000;
|
||||
--surface: #1c1c1e;
|
||||
--surface-2: #161618;
|
||||
--surface-3: #2c2c2e;
|
||||
--text: #f5f5f7;
|
||||
--text-2: #aeaeb2;
|
||||
--text-3: #636366;
|
||||
--border: rgba(255,255,255,0.08);
|
||||
--border-strong: rgba(255,255,255,0.14);
|
||||
--shadow-sm: 0 1px 2px rgba(0,0,0,0.6);
|
||||
--shadow-md: 0 6px 20px rgba(0,0,0,0.45);
|
||||
--teal-50: rgba(0,132,135,0.15);
|
||||
}
|
||||
|
||||
/* ── Base ── */
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
a { text-decoration: none; color: inherit; }
|
||||
button { font-family: inherit; }
|
||||
input, select { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
::selection { background: rgba(0,132,135,0.2); }
|
||||
|
||||
/* ── Layout ── */
|
||||
.app { display: grid; grid-template-columns: var(--sidebar-w) 1fr; min-height: 100vh; }
|
||||
.main { display: flex; flex-direction: column; min-width: 0; }
|
||||
|
||||
/* ── Sidebar ── */
|
||||
.sidebar {
|
||||
position: sticky; top: 0; height: 100vh;
|
||||
background: var(--surface-2);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex; flex-direction: column;
|
||||
padding: 14px 10px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.sidebar-brand {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 6px 8px 16px;
|
||||
}
|
||||
.brand-mark {
|
||||
width: 30px; height: 30px; border-radius: var(--r-sm);
|
||||
background: var(--teal); display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
||||
}
|
||||
.brand-mark svg { color: #fff; width: 17px; height: 17px; }
|
||||
.brand-name { font-size: 13.5px; font-weight: 700; color: var(--text); }
|
||||
.brand-sub { font-size: 11px; color: var(--text-3); }
|
||||
|
||||
.sidebar-section {
|
||||
font-size: 10.5px; font-weight: 600; color: var(--text-3);
|
||||
letter-spacing: 0.05em; text-transform: uppercase;
|
||||
padding: 12px 8px 5px;
|
||||
}
|
||||
.nav-item {
|
||||
display: flex; align-items: center; gap: 9px;
|
||||
padding: 7px 8px; border-radius: var(--r-sm);
|
||||
color: var(--text-2); font-size: 13.5px; font-weight: 500;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
cursor: pointer; border: none; background: none; width: 100%; text-align: left;
|
||||
}
|
||||
.nav-item:hover { background: var(--surface-3); color: var(--text); }
|
||||
.nav-item.active { background: var(--teal-50); color: var(--teal); }
|
||||
.nav-item svg { width: 17px; height: 17px; flex-shrink: 0; stroke-width: 1.75; }
|
||||
.nav-badge {
|
||||
margin-left: auto; background: var(--surface-3); color: var(--text-2);
|
||||
border-radius: 999px; padding: 1px 7px; font-size: 11px; font-weight: 600;
|
||||
}
|
||||
.nav-item.active .nav-badge { background: rgba(0,132,135,.18); color: var(--teal); }
|
||||
.nav-dot { margin-left: auto; width: 6px; height: 6px; border-radius: 50%; background: var(--success); }
|
||||
|
||||
.sidebar-footer {
|
||||
margin-top: auto; padding: 10px 8px 6px;
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
}
|
||||
.avatar {
|
||||
width: 27px; height: 27px; border-radius: 50%;
|
||||
background: linear-gradient(135deg, var(--teal), var(--teal-700));
|
||||
color: #fff; display: flex; align-items: center; justify-content: center;
|
||||
font-size: 11px; font-weight: 700; flex-shrink: 0;
|
||||
}
|
||||
.user-name { font-size: 12.5px; font-weight: 600; color: var(--text); }
|
||||
.user-sub { font-size: 11px; color: var(--text-3); }
|
||||
|
||||
/* ── Topbar ── */
|
||||
.topbar {
|
||||
height: var(--topbar-h); position: sticky; top: 0;
|
||||
background: color-mix(in srgb, var(--bg) 85%, transparent);
|
||||
backdrop-filter: saturate(180%) blur(20px);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex; align-items: center; padding: 0 28px; gap: 8px; z-index: 50;
|
||||
}
|
||||
.breadcrumb { flex: 1; display: flex; align-items: center; gap: 6px; font-size: 13px; color: var(--text-3); }
|
||||
.breadcrumb-sep { color: var(--text-3); }
|
||||
.breadcrumb-current { font-weight: 600; color: var(--text); }
|
||||
.topbar-actions { display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
/* ── Content area ── */
|
||||
.content { flex: 1; padding: 24px 28px; }
|
||||
|
||||
/* ── Page header ── */
|
||||
.page-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 20px; }
|
||||
.page-header h2, .page-title { font-size: 21px; font-weight: 700; letter-spacing: -0.3px; }
|
||||
.page-header p, .page-sub { font-size: 13px; color: var(--text-3); margin-top: 2px; }
|
||||
.page-actions { display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
/* ── Scan status pill (in page header) ── */
|
||||
.scan-pill {
|
||||
display: inline-flex; align-items: center; gap: 7px;
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: 999px; padding: 5px 12px 5px 10px;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.scan-pill .label { font-weight: 500; color: var(--text); }
|
||||
.scan-pill .sep { color: var(--text-3); }
|
||||
.scan-pill .site { color: var(--text-3); }
|
||||
|
||||
/* ── Status pill (topbar) ── */
|
||||
.status-pill {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 4px 10px; border-radius: 20px; font-size: 12px; font-weight: 500;
|
||||
background: var(--surface-3); color: var(--text-2);
|
||||
}
|
||||
.status-pill.online { background: rgba(52,168,83,.1); color: var(--success); }
|
||||
.status-pill.scanning { background: rgba(0,132,135,.1); color: var(--teal); }
|
||||
|
||||
/* ── Buttons ── */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 7px 14px; border-radius: var(--r-sm);
|
||||
font-size: 13px; font-weight: 500;
|
||||
cursor: pointer; border: 1px solid var(--border);
|
||||
background: var(--surface); color: var(--text);
|
||||
transition: background 0.12s, border-color 0.12s; white-space: nowrap;
|
||||
}
|
||||
.btn:hover { background: var(--surface-3); }
|
||||
.btn svg { width: 14px; height: 14px; stroke-width: 1.75; }
|
||||
.btn-primary { background: var(--teal); color: #fff; border-color: var(--teal); }
|
||||
.btn-primary:hover { background: var(--teal-600); border-color: var(--teal-600); }
|
||||
.btn-danger { background: var(--danger); color: #fff; border-color: var(--danger); }
|
||||
.btn-sm { padding: 5px 11px; font-size: 12.5px; }
|
||||
.btn-ghost { background: transparent; border-color: transparent; }
|
||||
.btn-ghost:hover { background: var(--surface-3); }
|
||||
.btn:disabled { opacity: .55; cursor: not-allowed; }
|
||||
|
||||
/* ── Icon button ── */
|
||||
.icon-btn {
|
||||
width: 30px; height: 30px; border-radius: var(--r-sm);
|
||||
border: 1px solid var(--border); background: var(--surface); color: var(--text-2);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
cursor: pointer; transition: background 0.12s;
|
||||
}
|
||||
.icon-btn:hover { background: var(--surface-3); color: var(--text); }
|
||||
.icon-btn svg { width: 14px; height: 14px; stroke-width: 1.75; }
|
||||
|
||||
/* ── Inputs ── */
|
||||
.input, .select {
|
||||
width: 100%; padding: 7.5px 12px;
|
||||
border: 1px solid var(--border-strong); border-radius: var(--r-sm);
|
||||
background: var(--surface); color: var(--text); font-size: 13.5px;
|
||||
outline: none; transition: border-color .14s, box-shadow .14s;
|
||||
}
|
||||
.input:focus, .select:focus {
|
||||
border-color: var(--teal);
|
||||
box-shadow: 0 0 0 3px rgba(0,132,135,.14);
|
||||
}
|
||||
.input::placeholder { color: var(--text-3); }
|
||||
.input-label { font-size: 12.5px; font-weight: 500; color: var(--text-2); margin-bottom: 5px; display: block; }
|
||||
|
||||
/* ── Toggle ── */
|
||||
.toggle { position: relative; display: inline-block; width: 36px; height: 22px; flex-shrink: 0; }
|
||||
.toggle input { opacity: 0; width: 0; height: 0; position: absolute; }
|
||||
.toggle-track {
|
||||
position: absolute; inset: 0; background: var(--surface-3);
|
||||
border: 1px solid var(--border); border-radius: 20px;
|
||||
cursor: pointer; transition: background .18s;
|
||||
}
|
||||
.toggle input:checked + .toggle-track { background: var(--teal); border-color: var(--teal); }
|
||||
.toggle-track::after {
|
||||
content: ''; position: absolute; left: 2px; top: 2px;
|
||||
width: 16px; height: 16px; border-radius: 50%;
|
||||
background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.2);
|
||||
transition: transform .18s;
|
||||
}
|
||||
.toggle input:checked + .toggle-track::after { transform: translateX(14px); }
|
||||
|
||||
/* ── Dot ── */
|
||||
.dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; display: inline-block; }
|
||||
.dot.green { background: var(--success); box-shadow: 0 0 0 2px rgba(52,168,83,.18); }
|
||||
.dot.red { background: var(--danger); }
|
||||
.dot.yellow { background: var(--warning); }
|
||||
.dot.gray { background: var(--text-3); }
|
||||
.dot.teal { background: var(--teal); }
|
||||
.dot.pulse { animation: dotpulse 1.6s ease-out infinite; }
|
||||
@keyframes dotpulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(52,168,83,0.5); }
|
||||
70% { box-shadow: 0 0 0 6px rgba(52,168,83,0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(52,168,83,0); }
|
||||
}
|
||||
|
||||
/* ── Badge ── */
|
||||
.badge {
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
padding: 2px 8px; border-radius: 999px; font-size: 11.5px; font-weight: 500;
|
||||
background: var(--surface-3); color: var(--text-2);
|
||||
}
|
||||
.badge-green { background: rgba(52,168,83,.12); color: #2c8a44; }
|
||||
.badge-red { background: rgba(229,72,77,.12); color: #c4393d; }
|
||||
.badge-yellow { background: rgba(232,162,58,.14); color: #b07820; }
|
||||
.badge-teal { background: var(--teal-50); color: var(--teal); }
|
||||
.badge-gray { background: var(--surface-3); color: var(--text-3); }
|
||||
[data-theme="dark"] .badge-green { color: #4cc471; }
|
||||
[data-theme="dark"] .badge-red { color: #ff6b6f; }
|
||||
[data-theme="dark"] .badge-yellow { color: #ffc46b; }
|
||||
[data-theme="dark"] .badge-teal { color: #4ec6c9; }
|
||||
|
||||
/* ── Card ── */
|
||||
.card {
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: var(--r-lg); overflow: hidden;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.card-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 14px 20px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.card-title { font-size: 14px; font-weight: 600; }
|
||||
.card-sub { font-size: 12px; color: var(--text-3); margin-top: 2px; }
|
||||
.card-action { font-size: 12.5px; color: var(--teal); display: flex; align-items: center; gap: 4px; }
|
||||
.card-action svg { width: 12px; height: 12px; }
|
||||
|
||||
/* ── Stats grid ── */
|
||||
.stats-grid { display: grid; gap: 16px; margin-bottom: 20px; }
|
||||
.stats-grid-4 { grid-template-columns: repeat(4, 1fr); }
|
||||
.stat {
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: var(--r-lg); padding: 18px 20px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.stat-icon { width: 14px; height: 14px; color: var(--text-3); stroke-width: 1.75; }
|
||||
.stat-label { font-size: 12.5px; color: var(--text-3); font-weight: 500; margin-bottom: 6px; display: flex; align-items: center; gap: 7px; }
|
||||
.stat-value { font-size: 30px; font-weight: 600; letter-spacing: -0.02em; margin-top: 2px; }
|
||||
.stat-delta { font-size: 12.5px; color: var(--text-3); margin-top: 4px; }
|
||||
.stat-delta.up { color: var(--success); }
|
||||
.stat-delta.down { color: var(--danger); }
|
||||
|
||||
/* ── Sparkline ── */
|
||||
.sparkline { display: flex; align-items: flex-end; gap: 2px; height: 24px; margin-top: 8px; }
|
||||
.sparkline span { flex: 1; background: var(--teal); border-radius: 1.5px; opacity: 0.75; }
|
||||
|
||||
/* ── Dashboard 2-col grid ── */
|
||||
.dash-grid { display: grid; grid-template-columns: 2fr 1fr; gap: 16px; }
|
||||
|
||||
/* ── Table ── */
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
thead th {
|
||||
padding: 10px 16px; text-align: left; white-space: nowrap;
|
||||
font-size: 11.5px; font-weight: 600; text-transform: uppercase; letter-spacing: .05em;
|
||||
color: var(--text-3); border-bottom: 1px solid var(--border);
|
||||
background: var(--surface-2); position: sticky; top: var(--topbar-h);
|
||||
}
|
||||
tbody td { padding: 12px 16px; font-size: 13px; border-bottom: 1px solid var(--border); }
|
||||
tbody tr:last-child td { border-bottom: none; }
|
||||
tbody tr:hover td { background: var(--surface-2); }
|
||||
.mono { font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace; font-size: 12.5px; }
|
||||
|
||||
/* ── Device icon cell ── */
|
||||
.device-icon {
|
||||
width: 28px; height: 28px; border-radius: 7px;
|
||||
background: var(--surface-3); color: var(--text-2);
|
||||
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
||||
}
|
||||
.device-icon svg { width: 14px; height: 14px; stroke-width: 1.75; }
|
||||
.device-row { display: flex; align-items: center; gap: 10px; }
|
||||
.device-name { font-size: 13px; font-weight: 600; }
|
||||
.device-vendor { font-size: 11.5px; color: var(--text-3); }
|
||||
|
||||
/* ── Filter chips ── */
|
||||
.filter-bar { display: flex; align-items: center; gap: 10px; padding: 12px 16px; flex-wrap: wrap; border-bottom: 1px solid var(--border); background: var(--surface); }
|
||||
.filter-chips { display: flex; gap: 5px; }
|
||||
.chip {
|
||||
padding: 4px 12px; border-radius: 999px; font-size: 12.5px; font-weight: 500;
|
||||
background: transparent; border: 1px solid var(--border-strong); color: var(--text-2);
|
||||
cursor: pointer; transition: all .12s;
|
||||
}
|
||||
.chip:hover { background: var(--surface-3); }
|
||||
.chip.active { background: var(--teal-50); border-color: transparent; color: var(--teal); }
|
||||
.search-wrap { position: relative; flex: 1; min-width: 200px; max-width: 300px; }
|
||||
.search-wrap svg { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); width: 14px; height: 14px; color: var(--text-3); pointer-events: none; }
|
||||
.search-wrap .input { padding-left: 32px; }
|
||||
|
||||
/* ── Module row ── */
|
||||
.module-row {
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
padding: 14px 20px; border-bottom: 1px solid var(--border); transition: background .12s;
|
||||
}
|
||||
.module-row:last-child { border-bottom: none; }
|
||||
.module-row:hover { background: var(--surface-2); }
|
||||
.module-icon {
|
||||
width: 34px; height: 34px; border-radius: var(--r-sm);
|
||||
background: var(--surface-3); color: var(--text-3);
|
||||
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
||||
}
|
||||
.module-icon.active { background: var(--teal-50); color: var(--teal); }
|
||||
.module-icon svg { width: 17px; height: 17px; stroke-width: 1.75; }
|
||||
.module-info { flex: 1; min-width: 0; }
|
||||
.module-name { font-size: 13.5px; font-weight: 600; display: flex; align-items: center; gap: 8px; }
|
||||
.module-desc { font-size: 12px; color: var(--text-3); margin-top: 2px; }
|
||||
.module-stats { display: flex; gap: 16px; font-size: 12px; color: var(--text-3); margin-top: 3px; }
|
||||
.module-stats strong { color: var(--text-2); font-weight: 500; }
|
||||
.module-config {
|
||||
border-top: 1px solid var(--border); padding: 18px 20px;
|
||||
background: var(--surface-2);
|
||||
display: grid; grid-template-columns: 1fr 1fr; gap: 14px;
|
||||
}
|
||||
|
||||
/* ── Dashboard module list row ── */
|
||||
.dash-module-row {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 11px 20px; border-top: 1px solid var(--border);
|
||||
}
|
||||
.dash-module-icon {
|
||||
width: 30px; height: 30px; border-radius: var(--r-sm);
|
||||
background: var(--teal-50); color: var(--teal);
|
||||
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
||||
}
|
||||
.dash-module-icon.off { background: var(--surface-3); color: var(--text-3); }
|
||||
.dash-module-icon svg { width: 15px; height: 15px; stroke-width: 1.75; }
|
||||
.dash-module-name { font-size: 13px; font-weight: 600; }
|
||||
.dash-module-sub { font-size: 11.5px; color: var(--text-3); }
|
||||
|
||||
/* ── Logs ── */
|
||||
.log-controls { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; }
|
||||
.log-container {
|
||||
background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--r-lg);
|
||||
height: 500px; overflow-y: auto;
|
||||
font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace; font-size: 12px;
|
||||
}
|
||||
.log-row {
|
||||
display: grid; grid-template-columns: 80px 48px 1fr;
|
||||
gap: 14px; padding: 6px 16px;
|
||||
border-bottom: 1px solid var(--border); align-items: start; line-height: 1.5;
|
||||
}
|
||||
.log-row:last-child { border-bottom: none; }
|
||||
.log-row:hover { background: var(--surface-3); }
|
||||
.log-time { color: var(--text-3); font-size: 11px; white-space: nowrap; padding-top: 1px; }
|
||||
.log-level { font-size: 10.5px; font-weight: 700; padding: 2px 6px; border-radius: 4px; text-align: center; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
.log-level.ok { background: rgba(52,168,83,.14); color: #2c8a44; }
|
||||
.log-level.info { background: rgba(47,125,241,.14); color: #2766c4; }
|
||||
.log-level.warn { background: rgba(232,162,58,.18); color: #b07820; }
|
||||
.log-level.err { background: rgba(229,72,77,.14); color: #c4393d; }
|
||||
[data-theme="dark"] .log-level.ok { color: #4cc471; }
|
||||
[data-theme="dark"] .log-level.info { color: #71aaff; }
|
||||
[data-theme="dark"] .log-level.warn { color: #ffc46b; }
|
||||
[data-theme="dark"] .log-level.err { color: #ff7679; }
|
||||
.log-msg { color: var(--text); word-break: break-word; }
|
||||
|
||||
/* ── Settings ── */
|
||||
.settings-section { margin-bottom: 24px; }
|
||||
.settings-title { font-size: 15px; font-weight: 700; margin-bottom: 14px; }
|
||||
.setting-row {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 14px 20px; border-bottom: 1px solid var(--border); gap: 20px;
|
||||
}
|
||||
.setting-row:last-child { border-bottom: none; }
|
||||
.setting-label { font-size: 13.5px; font-weight: 500; }
|
||||
.setting-sub { font-size: 12px; color: var(--text-3); margin-top: 2px; }
|
||||
.setting-body { padding: 18px 20px; display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.setting-body.full { grid-template-columns: 1fr; }
|
||||
.form-group { margin-bottom: 14px; }
|
||||
|
||||
/* ── Password wrap ── */
|
||||
.password-wrap { position: relative; }
|
||||
.password-wrap .input { padding-right: 40px; }
|
||||
.eye-btn {
|
||||
position: absolute; right: 10px; top: 50%; transform: translateY(-50%);
|
||||
background: none; border: none; cursor: pointer; color: var(--text-3); padding: 2px;
|
||||
}
|
||||
.eye-btn:hover { color: var(--text); }
|
||||
.eye-btn svg { width: 16px; height: 16px; stroke-width: 1.75; }
|
||||
|
||||
/* ── Auth / Login ── */
|
||||
.auth-wrap {
|
||||
min-height: 100vh; display: flex; align-items: center; justify-content: center;
|
||||
background: var(--bg); padding: 48px 24px;
|
||||
}
|
||||
.auth-card {
|
||||
width: 100%; max-width: 380px; background: var(--surface);
|
||||
border: 1px solid var(--border); border-radius: 18px; padding: 36px 30px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
display: flex; flex-direction: column; align-items: center; gap: 20px;
|
||||
}
|
||||
.auth-logo { height: 36px; }
|
||||
.auth-title { font-size: 20px; font-weight: 600; letter-spacing: -0.01em; margin: 0; }
|
||||
.auth-sub { font-size: 13px; color: var(--text-3); text-align: center; margin-top: -10px; }
|
||||
.auth-card form { width: 100%; display: flex; flex-direction: column; gap: 12px; }
|
||||
|
||||
/* ── Setup wizard ── */
|
||||
.wizard-wrap {
|
||||
min-height: 100vh; display: flex;
|
||||
align-items: safe center; justify-content: safe center;
|
||||
background: var(--bg); padding: 24px;
|
||||
}
|
||||
.wizard {
|
||||
width: 100%; max-width: 540px; background: var(--surface);
|
||||
border: 1px solid var(--border); border-radius: 18px; box-shadow: var(--shadow-lg); overflow: hidden;
|
||||
}
|
||||
.wizard-header { padding: 26px 30px 18px; border-bottom: 1px solid var(--border); }
|
||||
.wizard-steps { display: flex; gap: 6px; margin-top: 16px; }
|
||||
.wizard-step { flex: 1; height: 3px; border-radius: 999px; background: var(--surface-3); }
|
||||
.wizard-step.done, .wizard-step.active { background: var(--teal); }
|
||||
.wizard-body { padding: 26px 30px; min-height: 220px; }
|
||||
.wizard-footer {
|
||||
padding: 14px 30px; border-top: 1px solid var(--border);
|
||||
background: var(--surface-2);
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
}
|
||||
.wizard-icon {
|
||||
width: 48px; height: 48px; border-radius: var(--r-md);
|
||||
background: var(--teal-50); color: var(--teal);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.wizard-icon svg { width: 24px; height: 24px; stroke-width: 1.75; }
|
||||
.wizard-title { font-size: 20px; font-weight: 700; margin-bottom: 6px; }
|
||||
.wizard-sub { font-size: 13px; color: var(--text-3); margin-bottom: 20px; }
|
||||
.site-cards { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.site-card {
|
||||
border: 2px solid var(--border); border-radius: var(--r-md); padding: 16px;
|
||||
cursor: pointer; transition: border-color .15s, background .15s;
|
||||
}
|
||||
.site-card:hover { border-color: var(--teal-50); background: var(--surface-2); }
|
||||
.site-card.selected { border-color: var(--teal); background: var(--teal-50); }
|
||||
.site-card-name { font-weight: 600; font-size: 14px; }
|
||||
.site-card-sub { font-size: 12px; color: var(--text-3); margin-top: 3px; }
|
||||
|
||||
/* ── Pagination ── */
|
||||
.pagination { display: flex; align-items: center; gap: 3px; }
|
||||
.page-btn {
|
||||
min-width: 28px; height: 28px; border-radius: 6px;
|
||||
border: 1px solid transparent; background: transparent;
|
||||
color: var(--text-2); font-size: 12.5px; font-weight: 500; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center; padding: 0 8px;
|
||||
}
|
||||
.page-btn:hover { background: var(--surface-3); color: var(--text); }
|
||||
.page-btn.active { background: var(--teal); color: #fff; }
|
||||
.page-btn:disabled { opacity: .4; cursor: not-allowed; }
|
||||
|
||||
/* ── Error / Success msgs ── */
|
||||
.error-msg {
|
||||
background: rgba(229,72,77,.08); border: 1px solid rgba(229,72,77,.2);
|
||||
border-radius: var(--r-sm); padding: 10px 14px; font-size: 13px; color: var(--danger);
|
||||
}
|
||||
.success-msg {
|
||||
background: rgba(52,168,83,.08); border: 1px solid rgba(52,168,83,.2);
|
||||
border-radius: var(--r-sm); padding: 10px 14px; font-size: 13px; color: var(--success);
|
||||
}
|
||||
|
||||
/* ── Spinner ── */
|
||||
.spinner { width: 14px; height: 14px; border: 2px solid rgba(255,255,255,.3); border-top-color: #fff; border-radius: 50%; animation: spin .7s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* ── HTMX ── */
|
||||
.htmx-indicator { display: none; }
|
||||
.htmx-request .htmx-indicator { display: inline-flex; }
|
||||
|
||||
/* ── Utilities ── */
|
||||
.spacer { flex: 1; }
|
||||
.mono { font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace; font-size: 12.5px; }
|
||||
@@ -1,71 +0,0 @@
|
||||
{{define "content"}}
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">Active Directory</h1>
|
||||
<p class="page-sub">{{len .Computers}} Computer-Objekte aus {{.Domain}}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if not .ADEnabled}}
|
||||
<div style="background:rgba(232,162,58,.08);border:1px solid rgba(232,162,58,.3);border-radius:var(--r-lg);padding:20px 24px;margin-bottom:20px;">
|
||||
<div style="font-weight:600;color:var(--warning);margin-bottom:6px;">AD Sync nicht konfiguriert</div>
|
||||
<div style="font-size:13px;color:var(--text-2);">Trage Server, Bind-DN und Passwort in der <a href="/settings" style="color:var(--teal);">config.yaml</a> ein und aktiviere das Modul.</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="stats-grid" style="grid-template-columns:repeat(3,1fr);margin-bottom:20px;">
|
||||
<div class="stat">
|
||||
<div class="stat-label">Computer in AD</div>
|
||||
<div class="stat-value">{{len .Computers}}</div>
|
||||
<div class="stat-delta neutral">{{.Domain}}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">Im Netzwerk gesehen</div>
|
||||
<div class="stat-value" style="color:var(--success);">{{.SeenCount}}</div>
|
||||
<div class="stat-delta up">↑ aktiv erreichbar</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">Nicht gesehen</div>
|
||||
<div class="stat-value" style="{{if gt .MissingCount 0}}color:var(--warning);{{end}}">{{.MissingCount}}</div>
|
||||
<div class="stat-delta {{if gt .MissingCount 0}}down{{else}}neutral{{end}}">offline oder abwesend</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Computer-Name</th>
|
||||
<th>Abteilung / OU</th>
|
||||
<th>Betriebssystem</th>
|
||||
<th>Letzter Login (AD)</th>
|
||||
<th>Im Netzwerk</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Computers}}
|
||||
<tr>
|
||||
<td style="font-weight:600;">{{.CN}}</td>
|
||||
<td style="color:var(--text-2);">{{if .Department}}{{.Department}}{{else}}<span style="color:var(--text-3);">—</span>{{end}}</td>
|
||||
<td style="color:var(--text-2);font-size:12px;">{{if .OS}}{{.OS}}{{else}}<span style="color:var(--text-3);">—</span>{{end}}</td>
|
||||
<td style="color:var(--text-3);font-size:12px;">
|
||||
{{if not .LastLogon.IsZero}}{{formatTime .LastLogon}}{{else}}—{{end}}
|
||||
</td>
|
||||
<td>
|
||||
{{if .SeenInNetwork}}
|
||||
<span class="badge badge-green"><span class="dot green" style="width:5px;height:5px;margin-right:4px;"></span>Online</span>
|
||||
{{else}}
|
||||
<span class="badge badge-yellow">Nicht gesehen</span>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="5" style="text-align:center;padding:48px;color:var(--text-3);">
|
||||
Noch keine AD-Daten — AD Sync ausführen.
|
||||
</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -1,126 +0,0 @@
|
||||
{{define "content"}}
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">Geräte-Inventar</h2>
|
||||
<p class="page-sub">{{.TotalHosts}} Einträge · zuletzt aktualisiert {{.LastScan}}</p>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<a href="/assets/export.csv" class="btn">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg>
|
||||
CSV Export
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter bar -->
|
||||
<div class="card" style="margin-bottom:16px;overflow:visible;">
|
||||
<form method="GET" action="/assets">
|
||||
<div class="filter-bar">
|
||||
<div class="search-wrap">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
|
||||
<input class="input" type="search" name="q" value="{{.Query}}" placeholder="IP, Hostname oder MAC…">
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:6px;">
|
||||
<span style="font-size:12px;color:var(--text-3);white-space:nowrap;">Standort:</span>
|
||||
<div class="filter-chips">
|
||||
<button type="submit" name="site" value="" class="chip{{if eq .SiteFilter ""}} active{{end}}">Alle</button>
|
||||
<button type="submit" name="site" value="LUD" class="chip{{if eq .SiteFilter "LUD"}} active{{end}}">Lüdenscheid</button>
|
||||
<button type="submit" name="site" value="BAR" class="chip{{if eq .SiteFilter "BAR"}} active{{end}}">Barleben</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:6px;">
|
||||
<span style="font-size:12px;color:var(--text-3);white-space:nowrap;">Status:</span>
|
||||
<div class="filter-chips">
|
||||
<button type="submit" name="status" value="" class="chip{{if eq .StatusFilter ""}} active{{end}}">Alle</button>
|
||||
<button type="submit" name="status" value="online" class="chip{{if eq .StatusFilter "online"}} active{{end}}">Online</button>
|
||||
<button type="submit" name="status" value="offline" class="chip{{if eq .StatusFilter "offline"}} active{{end}}">Offline</button>
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" name="site" value="{{.SiteFilter}}">
|
||||
<input type="hidden" name="status" value="{{.StatusFilter}}">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="padding-left:20px;">IP-Adresse</th>
|
||||
<th>MAC-Adresse</th>
|
||||
<th>Hersteller</th>
|
||||
<th>Hostname</th>
|
||||
<th>Offene Ports</th>
|
||||
<th>Standort</th>
|
||||
<th>Zuletzt gesehen</th>
|
||||
<th style="padding-right:20px;">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Hosts}}
|
||||
<tr>
|
||||
<td style="padding-left:20px;" class="mono" style="font-weight:600;">{{.IP}}</td>
|
||||
<td class="mono" style="color:var(--text-3);">{{.MAC}}</td>
|
||||
<td>
|
||||
<div class="device-row">
|
||||
<div class="device-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="3" width="20" height="8" rx="2"/><rect x="2" y="13" width="20" height="8" rx="2"/><path d="M6 7h.01M6 17h.01"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span style="color:var(--text-2);">{{if .Vendor}}{{.Vendor}}{{else}}<span style="color:var(--text-3);">—</span>{{end}}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td style="color:var(--text-2);font-size:12.5px;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{if .Hostname}}{{.Hostname}}{{else}}<span style="color:var(--text-3);">—</span>{{end}}</td>
|
||||
<td>
|
||||
{{if .OpenPorts}}
|
||||
<div style="display:flex;flex-wrap:wrap;gap:3px;">
|
||||
{{range .OpenPorts}}<span style="background:var(--teal-50);color:var(--teal);border-radius:3px;padding:1px 5px;font-size:10px;font-family:monospace;font-weight:600;">{{.}}</span>{{end}}
|
||||
</div>
|
||||
{{else}}<span style="color:var(--text-3);">—</span>{{end}}
|
||||
</td>
|
||||
<td><span class="badge badge-gray">{{.Site}}</span></td>
|
||||
<td style="color:var(--text-3);font-size:12px;">{{.LastSeenFmt}}</td>
|
||||
<td style="padding-right:20px;">
|
||||
{{if eq .Status "online"}}
|
||||
<span class="badge badge-green"><span class="dot green" style="width:5px;height:5px;margin-right:4px;"></span>Online</span>
|
||||
{{else}}
|
||||
<span class="badge badge-gray"><span class="dot gray" style="width:5px;height:5px;margin-right:4px;"></span>Offline</span>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="8" style="text-align:center;padding:48px;color:var(--text-3);">
|
||||
Keine Geräte gefunden.
|
||||
</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{{if gt .TotalPages 1}}
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:12px 20px;border-top:1px solid var(--border);background:var(--surface-2);">
|
||||
<span style="font-size:12.5px;color:var(--text-3);">
|
||||
Zeige {{if gt .TotalHosts 0}}{{inc (mul (dec .PageNum) 20)}}–{{end}}{{.TotalHosts}} Einträge
|
||||
</span>
|
||||
<div class="pagination">
|
||||
{{if gt .PageNum 1}}
|
||||
<a href="/assets?{{.QueryString}}&page={{dec .PageNum}}" class="page-btn">‹</a>
|
||||
{{else}}
|
||||
<button class="page-btn" disabled>‹</button>
|
||||
{{end}}
|
||||
|
||||
{{range .Pages}}
|
||||
<a href="/assets?{{$.QueryString}}&page={{.}}" class="page-btn{{if eq . $.PageNum}} active{{end}}">{{.}}</a>
|
||||
{{end}}
|
||||
|
||||
{{if lt .PageNum .TotalPages}}
|
||||
<a href="/assets?{{.QueryString}}&page={{inc .PageNum}}" class="page-btn">›</a>
|
||||
{{else}}
|
||||
<button class="page-btn" disabled>›</button>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -1,166 +0,0 @@
|
||||
{{define "content"}}
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">Übersicht</h2>
|
||||
<p class="page-sub">Status deines lokalen Scanners und der gefundenen Assets.</p>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:10px;">
|
||||
<div class="scan-pill">
|
||||
{{if .Scanning}}<span class="dot green" style="animation:dotpulse 1.6s ease-out infinite;"></span>
|
||||
<span class="label">Scanner läuft</span>
|
||||
{{else}}<span class="dot yellow"></span>
|
||||
<span class="label">Bereit</span>{{end}}
|
||||
<span class="sep">·</span>
|
||||
<span class="site">Standort {{.SiteID}}</span>
|
||||
</div>
|
||||
<form method="POST" action="/scan/start" style="margin:0;">
|
||||
<button type="submit" class="btn btn-primary" {{if .Scanning}}disabled{{end}}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" style="width:13px;height:13px;">
|
||||
{{if .Scanning}}<rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/>
|
||||
{{else}}<polygon points="6 4 20 12 6 20 6 4" fill="currentColor" stroke="none"/>{{end}}
|
||||
</svg>
|
||||
{{if .Scanning}}Scan läuft…{{else}}Jetzt scannen{{end}}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="stats-grid stats-grid-4">
|
||||
<div class="stat">
|
||||
<div class="stat-label">
|
||||
<svg class="stat-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="8" rx="2"/><rect x="2" y="13" width="20" height="8" rx="2"/></svg>
|
||||
Gefundene Geräte
|
||||
</div>
|
||||
<div class="stat-value">{{.HostCount}}</div>
|
||||
<div class="stat-delta up">+{{.RecentCount}} seit letztem Scan</div>
|
||||
<div class="sparkline">
|
||||
<span style="height:35%"></span><span style="height:42%"></span><span style="height:50%"></span><span style="height:58%"></span><span style="height:62%"></span><span style="height:70%"></span><span style="height:78%"></span><span style="height:82%"></span><span style="height:88%"></span><span style="height:92%"></span><span style="height:95%"></span><span style="height:100%"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">
|
||||
<svg class="stat-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></svg>
|
||||
Neu seit letztem Scan
|
||||
</div>
|
||||
<div class="stat-value">{{.RecentCount}}</div>
|
||||
<div class="stat-delta">In den letzten 60 Min</div>
|
||||
<div class="sparkline">
|
||||
<span style="height:20%"></span><span style="height:30%"></span><span style="height:25%"></span><span style="height:45%"></span><span style="height:55%"></span><span style="height:60%"></span><span style="height:70%"></span><span style="height:65%"></span><span style="height:80%"></span><span style="height:75%"></span><span style="height:85%"></span><span style="height:90%"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">
|
||||
<svg class="stat-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0zM12 9v4M12 17h.01"/></svg>
|
||||
Offline-Geräte
|
||||
</div>
|
||||
<div class="stat-value" style="{{if gt .OfflineCount 5}}color:var(--danger);{{end}}">{{.OfflineCount}}</div>
|
||||
<div class="stat-delta {{if gt .OfflineCount 5}}down{{end}}">{{if gt .OfflineCount 5}}Erhöht ggü. Vorwoche{{else}}Im Normalbereich{{end}}</div>
|
||||
<div class="sparkline">
|
||||
<span style="height:60%"></span><span style="height:70%"></span><span style="height:55%"></span><span style="height:65%"></span><span style="height:80%"></span><span style="height:60%"></span><span style="height:75%"></span><span style="height:65%"></span><span style="height:70%"></span><span style="height:85%"></span><span style="height:90%"></span><span style="height:70%"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">
|
||||
<svg class="stat-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||
Letzter Scan
|
||||
</div>
|
||||
<div class="stat-value" style="font-size:26px;margin-top:4px;">{{.LastScan}}</div>
|
||||
<div class="stat-delta">Intervall: {{.ARPInterval}}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2-col content -->
|
||||
<div class="dash-grid">
|
||||
<!-- Zuletzt gefundene Geräte -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<div class="card-title">Zuletzt gefundene Geräte</div>
|
||||
<div class="card-sub">Die 5 jüngsten Einträge im Inventar</div>
|
||||
</div>
|
||||
<a href="/assets" class="card-action">
|
||||
Alle anzeigen
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="padding-left:20px;">Gerät</th>
|
||||
<th>IP-Adresse</th>
|
||||
<th>Standort</th>
|
||||
<th>Status</th>
|
||||
<th style="padding-right:20px;text-align:right;">Gesehen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .RecentHosts}}
|
||||
<tr>
|
||||
<td style="padding-left:20px;">
|
||||
<div class="device-row">
|
||||
<div class="device-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="8" rx="2"/><rect x="2" y="13" width="20" height="8" rx="2"/><path d="M6 7h.01M6 17h.01"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="device-name">{{if .Hostname}}{{.Hostname}}{{else}}{{.IP}}{{end}}</div>
|
||||
<div class="device-vendor">{{if .Vendor}}{{.Vendor}}{{else}}Unbekannt{{end}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="mono">{{.IP}}</td>
|
||||
<td><span class="badge badge-gray">{{.Site}}</span></td>
|
||||
<td>
|
||||
{{if eq .Status "online"}}
|
||||
<span class="badge badge-green"><span class="dot green" style="width:5px;height:5px;margin-right:4px;"></span>Online</span>
|
||||
{{else}}
|
||||
<span class="badge badge-red"><span class="dot red" style="width:5px;height:5px;margin-right:4px;"></span>Offline</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td style="padding-right:20px;text-align:right;color:var(--text-3);font-size:12px;">{{.LastSeenFmt}}</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="5" style="text-align:center;padding:40px;color:var(--text-3);">
|
||||
Noch keine Geräte — Scan starten.
|
||||
</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Aktive Module -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<div class="card-title">Aktive Module</div>
|
||||
<div class="card-sub">Status der Scan-Quellen</div>
|
||||
</div>
|
||||
<a href="/modules" class="card-action">
|
||||
Verwalten
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
{{range $i, $m := .Modules}}
|
||||
<div class="dash-module-row" style="{{if eq $i 0}}border-top:none;{{end}}">
|
||||
<div class="dash-module-icon{{if not $m.Enabled}} off{{end}}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" style="width:15px;height:15px;">
|
||||
{{if eq $m.Name "arp_discovery"}}<path d="M5 12.55a11 11 0 0 1 14 0M1.42 9a16 16 0 0 1 21.16 0M8.53 16.11a6 6 0 0 1 6.95 0M12 20h.01"/>
|
||||
{{else if eq $m.Name "dns_reverse"}}<path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z"/>
|
||||
{{else if eq $m.Name "ad_sync"}}<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||
{{else if eq $m.Name "site_monitoring"}}<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
|
||||
{{else if eq $m.Name "port_scan"}}<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||
{{else if eq $m.Name "snmp"}}<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/>
|
||||
{{else}}<rect x="2" y="3" width="20" height="8" rx="2"/><rect x="2" y="13" width="20" height="8" rx="2"/>{{end}}
|
||||
</svg>
|
||||
</div>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div class="dash-module-name">{{$m.DisplayName}}</div>
|
||||
<div class="dash-module-sub">{{if $m.LastRun}}Letzter Lauf {{$m.LastRun}}{{else}}Noch nicht gelaufen{{end}}</div>
|
||||
</div>
|
||||
{{if $m.Enabled}}<span class="badge badge-green" style="font-size:11px;flex-shrink:0;">OK</span>
|
||||
{{else}}<span class="badge badge-gray" style="font-size:11px;flex-shrink:0;">Aus</span>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -1,145 +0,0 @@
|
||||
{{define "layout"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="de" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.Title}} — Nexus Scanner</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<script>
|
||||
(function(){
|
||||
var t = localStorage.getItem('nx-theme') || 'light';
|
||||
document.documentElement.setAttribute('data-theme', t);
|
||||
})();
|
||||
</script>
|
||||
<script src="https://unpkg.com/htmx.org@1.9.12" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
|
||||
<!-- Sidebar -->
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-brand">
|
||||
<div class="brand-mark">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="brand-name">Nexus Scanner</div>
|
||||
<div class="brand-sub">{{.SiteID}} · {{.Addr}}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section">Scanner</div>
|
||||
<nav>
|
||||
<a href="/dashboard" class="nav-item{{if eq .Page "dashboard"}} active{{end}}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="9" rx="1.5"/><rect x="14" y="3" width="7" height="5" rx="1.5"/><rect x="14" y="12" width="7" height="9" rx="1.5"/><rect x="3" y="16" width="7" height="5" rx="1.5"/></svg>
|
||||
<span>Übersicht</span>
|
||||
</a>
|
||||
<a href="/assets" class="nav-item{{if eq .Page "assets"}} active{{end}}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M3 9h18M9 9v11"/></svg>
|
||||
<span>Geräte</span>
|
||||
<span class="nav-badge">{{.HostCount}}</span>
|
||||
</a>
|
||||
<a href="/modules" class="nav-item{{if eq .Page "modules"}} active{{end}}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></svg>
|
||||
<span>Module</span>
|
||||
</a>
|
||||
<a href="/snmp" class="nav-item{{if eq .Page "snmp"}} active{{end}}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>
|
||||
<span>SNMP</span>
|
||||
</a>
|
||||
<a href="/monitoring" class="nav-item{{if eq .Page "monitoring"}} active{{end}}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
|
||||
<span>Monitoring</span>
|
||||
{{if .Scanning}}<span class="nav-dot"></span>{{end}}
|
||||
</a>
|
||||
<a href="/ad" class="nav-item{{if eq .Page "ad"}} active{{end}}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||
<span>Active Directory</span>
|
||||
</a>
|
||||
<a href="/logs" class="nav-item{{if eq .Page "logs"}} active{{end}}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h16M4 12h16M4 18h10"/></svg>
|
||||
<span>Logs</span>
|
||||
<span class="nav-dot"></span>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-section">System</div>
|
||||
<nav>
|
||||
<a href="/settings" class="nav-item{{if eq .Page "settings"}} active{{end}}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/></svg>
|
||||
<span>Einstellungen</span>
|
||||
</a>
|
||||
<button class="nav-item" onclick="toggleTheme()">
|
||||
<svg id="theme-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg>
|
||||
<span id="theme-label">Dunkler Modus</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<div class="avatar">A</div>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div class="user-name">Administrator</div>
|
||||
<div class="user-sub">Eingeloggt</div>
|
||||
</div>
|
||||
<a href="/logout" class="icon-btn" title="Abmelden">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main -->
|
||||
<div class="main">
|
||||
<header class="topbar">
|
||||
<div class="breadcrumb">
|
||||
<span>Nexus Scanner</span>
|
||||
<span class="breadcrumb-sep">/</span>
|
||||
<span class="breadcrumb-current">{{.Title}}</span>
|
||||
</div>
|
||||
<div class="topbar-actions">
|
||||
{{if .Scanning}}
|
||||
<div class="status-pill scanning"><span class="dot teal" style="animation:dotpulse 1.6s ease-out infinite;"></span> Scan läuft…</div>
|
||||
{{else}}
|
||||
<div class="status-pill online"><span class="dot green"></span> Online · {{.SiteID}}</div>
|
||||
{{end}}
|
||||
<a href="/logout" class="icon-btn" title="Abmelden">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="content">
|
||||
{{template "content" .}}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function toggleTheme() {
|
||||
var cur = document.documentElement.getAttribute('data-theme') || 'light';
|
||||
var next = cur === 'dark' ? 'light' : 'dark';
|
||||
document.documentElement.setAttribute('data-theme', next);
|
||||
localStorage.setItem('nx-theme', next);
|
||||
updateThemeUI(next);
|
||||
}
|
||||
function updateThemeUI(t) {
|
||||
var icon = document.getElementById('theme-icon');
|
||||
var label = document.getElementById('theme-label');
|
||||
if (!icon || !label) return;
|
||||
if (t === 'dark') {
|
||||
icon.innerHTML = '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/>';
|
||||
label.textContent = 'Heller Modus';
|
||||
} else {
|
||||
icon.innerHTML = '<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>';
|
||||
label.textContent = 'Dunkler Modus';
|
||||
}
|
||||
}
|
||||
(function(){
|
||||
updateThemeUI(document.documentElement.getAttribute('data-theme') || 'light');
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -1,71 +0,0 @@
|
||||
{{define "login"}}
|
||||
<!DOCTYPE html>
|
||||
<html data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Anmelden — Nexus Scanner</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<script src="https://unpkg.com/htmx.org@1.9.12" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="auth-wrap">
|
||||
<div class="auth-card">
|
||||
<div class="auth-logo">
|
||||
<div class="brand-mark">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="brand-name">Nexus Scanner</div>
|
||||
<div class="brand-sub">Netzwerk-Inventar Agent</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 class="auth-title">Willkommen zurück.</h1>
|
||||
<p class="auth-sub">Passwort eingeben um fortzufahren.</p>
|
||||
|
||||
{{if .Error}}
|
||||
<div class="error-msg">{{.Error}}</div>
|
||||
{{end}}
|
||||
|
||||
<form method="POST" action="/login">
|
||||
<div class="form-group">
|
||||
<label class="input-label" for="password">Passwort</label>
|
||||
<div class="password-wrap">
|
||||
<input class="input" type="password" id="password" name="password"
|
||||
placeholder="••••••••" autocomplete="current-password" autofocus required>
|
||||
<button type="button" class="eye-btn" onclick="togglePw()" title="Anzeigen">
|
||||
<svg id="eye-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-full" style="justify-content:center;">
|
||||
Anmelden
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p style="font-size:12px;color:var(--text-3);text-align:center;margin-top:20px;">
|
||||
Nexus Scanner · Cereda Systems GmbH
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
function togglePw() {
|
||||
const f = document.getElementById('password');
|
||||
const i = document.getElementById('eye-icon');
|
||||
if (f.type === 'password') {
|
||||
f.type = 'text';
|
||||
i.innerHTML = '<path d="M17.94 17.94A10.94 10.94 0 0 1 12 20c-6.5 0-10-7-10-7a18.4 18.4 0 0 1 4.06-4.94"/><path d="M9.9 4.24A10 10 0 0 1 12 4c6.5 0 10 7 10 7a18.4 18.4 0 0 1-3.16 4.19"/><path d="m2 2 20 20"/>';
|
||||
} else {
|
||||
f.type = 'password';
|
||||
i.innerHTML = '<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/>';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -1,57 +0,0 @@
|
||||
{{define "content"}}
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">Logs</h1>
|
||||
<p class="page-sub">Strukturierte Ausgabe des Scanners in Echtzeit.</p>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<form method="GET" action="/logs" style="display:flex;gap:8px;align-items:center;">
|
||||
<select class="select" name="level" style="width:auto;" onchange="this.form.submit()">
|
||||
<option value="">Alle Level</option>
|
||||
<option value="INFO"{{if eq .LevelFilter "INFO"}} selected{{end}}>INFO</option>
|
||||
<option value="WARN"{{if eq .LevelFilter "WARN"}} selected{{end}}>WARN</option>
|
||||
<option value="ERROR"{{if eq .LevelFilter "ERROR"}} selected{{end}}>ERROR</option>
|
||||
</select>
|
||||
</form>
|
||||
<button class="btn" onclick="clearLogs()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
|
||||
Leeren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="log-container" id="log-container"
|
||||
hx-get="/logs/rows?level={{.LevelFilter}}"
|
||||
hx-trigger="every 2s"
|
||||
hx-swap="innerHTML"
|
||||
hx-target="this">
|
||||
{{template "log-rows" .}}
|
||||
</div>
|
||||
|
||||
<div style="margin-top:10px;font-size:12px;color:var(--text-3);text-align:right;">
|
||||
Aktualisiert automatisch alle 2 Sekunden
|
||||
<span id="log-count"> · {{len .Logs}} Einträge</span>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function clearLogs() {
|
||||
fetch('/logs/clear', {method:'POST'}).then(() => {
|
||||
document.getElementById('log-container').innerHTML = '';
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
{{define "log-rows"}}
|
||||
{{range .Logs}}
|
||||
<div class="log-row">
|
||||
<span class="log-time">{{.Time}}</span>
|
||||
<span class="log-level {{.LevelClass}}">{{.Level}}</span>
|
||||
<span class="log-msg">{{.Message}}</span>
|
||||
</div>
|
||||
{{else}}
|
||||
<div style="padding:48px;text-align:center;color:var(--text-3);font-size:13px;">
|
||||
Noch keine Log-Einträge.
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -1,69 +0,0 @@
|
||||
{{define "content"}}
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">Module</h1>
|
||||
<p class="page-sub">Scanner-Module aktivieren, deaktivieren und konfigurieren.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
{{range .Modules}}
|
||||
<div class="module-row">
|
||||
<div class="module-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="module-info">
|
||||
<div class="module-name">{{.DisplayName}}</div>
|
||||
<div class="module-desc">{{.Description}} · alle {{.Interval}}</div>
|
||||
</div>
|
||||
|
||||
<div class="module-status">
|
||||
{{if .LastRun}}
|
||||
<span style="font-size:12px;color:var(--text-3);">Letzter Lauf: {{.LastRun}}</span>
|
||||
{{end}}
|
||||
{{if .Enabled}}
|
||||
<span class="badge badge-green">Aktiv</span>
|
||||
{{else}}
|
||||
<span class="badge badge-gray">Inaktiv</span>
|
||||
{{end}}
|
||||
<form method="POST" action="/modules/{{.Name}}/toggle" style="display:inline;">
|
||||
<label class="toggle" title="{{if .Enabled}}Deaktivieren{{else}}Aktivieren{{end}}">
|
||||
<input type="checkbox"{{if .Enabled}} checked{{end}} onchange="this.form.submit()">
|
||||
<span class="toggle-track"></span>
|
||||
</label>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div style="padding:48px;text-align:center;color:var(--text-3);">
|
||||
Keine Module registriert. Konfiguriere Module in config.yaml.
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-top:16px;">
|
||||
<div class="card-header">
|
||||
<span class="card-title">ARP-Discovery Konfiguration</span>
|
||||
</div>
|
||||
<form method="POST" action="/modules/arp_discovery/config" class="setting-body full">
|
||||
<div class="form-group">
|
||||
<label class="input-label">Subnetz (CIDR)</label>
|
||||
{{range .ARPSubnets}}
|
||||
<input class="input mb-3" type="text" name="subnet" value="{{.}}" placeholder="192.168.0.0/24">
|
||||
{{else}}
|
||||
<input class="input mb-3" type="text" name="subnet" value="" placeholder="192.168.0.0/24">
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="input-label">Netzwerk-Interface</label>
|
||||
<input class="input" type="text" name="interface" value="{{.ARPInterface}}" placeholder="eth0">
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit" class="btn btn-primary">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user