diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..8acb039
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,106 @@
+# Security Policy
+
+## Supported Versions
+
+| Version | Supported |
+| ------- | ------------------ |
+| main | :white_check_mark: |
+
+We only support the latest version on the `main` branch. Please ensure you are running the most recent version before reporting a vulnerability.
+
+## Reporting a Vulnerability
+
+We take security vulnerabilities in the telemetry-service seriously. If you discover a security issue, please report it responsibly.
+
+### How to Report
+
+**DO NOT** open a public GitHub issue for security vulnerabilities.
+
+Instead, please report security issues via one of these methods:
+
+1. **GitHub Security Advisories** (preferred):
+ - Go to [Security Advisories](https://github.com/community-scripts/telemetry-service/security/advisories)
+ - Click "Report a vulnerability"
+ - Fill out the form with details
+
+2. **Email**: Contact the maintainers through the [ProxmoxVE repository](https://github.com/community-scripts/ProxmoxVE)
+
+### What to Include
+
+Please provide:
+
+- A clear description of the vulnerability
+- Steps to reproduce the issue
+- Potential impact assessment
+- Any suggested fixes (if available)
+- Your contact information for follow-up
+
+### Response Timeline
+
+| Action | Timeline |
+|--------|----------|
+| Initial acknowledgment | 48 hours |
+| Status update | 7 days |
+| Fix release (critical) | 14 days |
+| Fix release (non-critical) | 30 days |
+
+## Security Measures
+
+This service implements the following security controls:
+
+### Data Protection
+- **No PII Collection**: No personally identifiable information is collected
+- **No IP Logging**: Request logging is disabled by default (`ENABLE_REQUEST_LOGGING=false`)
+- **Anonymous Sessions**: Session IDs are randomly generated UUIDs with no user correlation
+- **Data Minimization**: Only technical metrics necessary for analytics are collected
+
+### Transport Security
+- **TLS 1.3**: All communications encrypted in transit
+- **HTTPS Only**: No plaintext HTTP endpoints in production
+
+### Access Control
+- **API Token Authentication**: PocketBase API requires authentication tokens
+- **Rate Limiting**: Configurable per-IP rate limiting prevents abuse
+- **No Public Write Access**: Only the telemetry endpoint accepts writes
+
+### Infrastructure
+- **EU Data Residency**: All data stored on EU servers (Hetzner, Germany)
+- **Container Isolation**: Runs in isolated Docker containers
+- **Minimal Attack Surface**: Alpine-based image with no shell access
+
+## Known Limitations
+
+- **No End-to-End Encryption**: Data is encrypted in transit but stored unencrypted at rest (mitigated by database access controls)
+- **No User Authentication**: The telemetry endpoint accepts anonymous submissions (by design)
+
+## Security Headers
+
+The service sets the following security headers on all responses:
+
+```
+X-Content-Type-Options: nosniff
+X-Frame-Options: DENY
+Referrer-Policy: no-referrer
+```
+
+## Audit Log
+
+Security-relevant actions are logged:
+- Authentication attempts to PocketBase
+- Rate limit violations
+- Failed data validations
+
+Logs do not contain IP addresses or user-identifiable information.
+
+## Compliance
+
+This service is designed to be compliant with:
+
+- **GDPR/DSGVO**: No personal data processing, Privacy by Design
+- **CCPA**: No sale of personal information (no personal information collected)
+
+See [docs/VVT.md](docs/VVT.md) for the full Record of Processing Activities.
+
+---
+
+*Last updated: 2026-02-12*
diff --git a/cleanup.go b/cleanup.go
index 5b0e672..39402ba 100644
--- a/cleanup.go
+++ b/cleanup.go
@@ -12,9 +12,11 @@ import (
// CleanupConfig holds configuration for the cleanup job
type CleanupConfig struct {
- Enabled bool
- CheckInterval time.Duration // How often to run cleanup
- StuckAfterHours int // Consider "installing" as stuck after X hours
+ Enabled bool
+ CheckInterval time.Duration // How often to run cleanup
+ StuckAfterHours int // Consider "installing" as stuck after X hours
+ RetentionDays int // Delete records older than X days (0 = disabled)
+ RetentionEnabled bool // Enable automatic data retention/deletion
}
// Cleaner handles cleanup of stuck installations
@@ -40,6 +42,12 @@ func (c *Cleaner) Start() {
go c.cleanupLoop()
log.Printf("INFO: cleanup job started (interval: %v, stuck after: %d hours)", c.cfg.CheckInterval, c.cfg.StuckAfterHours)
+
+ // Start retention job if enabled
+ if c.cfg.RetentionEnabled && c.cfg.RetentionDays > 0 {
+ go c.retentionLoop()
+ log.Printf("INFO: data retention job started (delete after: %d days)", c.cfg.RetentionDays)
+ }
}
func (c *Cleaner) cleanupLoop() {
@@ -171,3 +179,183 @@ func (c *Cleaner) GetStuckCount(ctx context.Context) (int, error) {
}
return len(records), nil
}
+
+// =============================================
+// DATA RETENTION (GDPR Löschkonzept)
+// =============================================
+
+// retentionLoop runs the data retention job periodically (once per day)
+func (c *Cleaner) retentionLoop() {
+ // Run once on startup after a delay
+ time.Sleep(5 * time.Minute)
+ c.runRetention()
+
+ // Run daily at 3:00 AM
+ ticker := time.NewTicker(24 * time.Hour)
+ defer ticker.Stop()
+
+ for range ticker.C {
+ c.runRetention()
+ }
+}
+
+// runRetention deletes records older than RetentionDays
+func (c *Cleaner) runRetention() {
+ if c.cfg.RetentionDays <= 0 {
+ return
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
+ defer cancel()
+
+ log.Printf("INFO: retention - starting cleanup of records older than %d days", c.cfg.RetentionDays)
+
+ deleted, err := c.deleteOldRecords(ctx)
+ if err != nil {
+ log.Printf("WARN: retention - failed to delete old records: %v", err)
+ return
+ }
+
+ if deleted > 0 {
+ log.Printf("INFO: retention - deleted %d records older than %d days", deleted, c.cfg.RetentionDays)
+ } else {
+ log.Printf("INFO: retention - no records to delete")
+ }
+}
+
+// deleteOldRecords finds and deletes records older than RetentionDays
+func (c *Cleaner) deleteOldRecords(ctx context.Context) (int, error) {
+ if err := c.pb.ensureAuth(ctx); err != nil {
+ return 0, err
+ }
+
+ // Calculate cutoff date
+ cutoff := time.Now().AddDate(0, 0, -c.cfg.RetentionDays)
+ cutoffStr := cutoff.Format("2006-01-02 00:00:00")
+
+ deleted := 0
+ page := 1
+ maxDeletePerRun := 1000 // Limit to prevent timeout
+
+ for deleted < maxDeletePerRun {
+ // Find old records
+ filter := url.QueryEscape(fmt.Sprintf("created<'%s'", cutoffStr))
+ reqURL := fmt.Sprintf("%s/api/collections/%s/records?filter=%s&perPage=100&page=%d",
+ c.pb.baseURL, c.pb.targetColl, filter, page)
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
+ if err != nil {
+ return deleted, err
+ }
+ req.Header.Set("Authorization", "Bearer "+c.pb.token)
+
+ resp, err := c.pb.http.Do(req)
+ if err != nil {
+ return deleted, err
+ }
+
+ var result struct {
+ Items []struct {
+ ID string `json:"id"`
+ } `json:"items"`
+ TotalItems int `json:"totalItems"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+ resp.Body.Close()
+ return deleted, err
+ }
+ resp.Body.Close()
+
+ if len(result.Items) == 0 {
+ break
+ }
+
+ // Delete each record
+ for _, item := range result.Items {
+ if err := c.deleteRecord(ctx, item.ID); err != nil {
+ log.Printf("WARN: retention - failed to delete record %s: %v", item.ID, err)
+ continue
+ }
+ deleted++
+
+ if deleted >= maxDeletePerRun {
+ log.Printf("INFO: retention - reached max delete limit (%d), will continue next run", maxDeletePerRun)
+ return deleted, nil
+ }
+ }
+
+ // Don't increment page since we deleted records
+ }
+
+ return deleted, nil
+}
+
+// deleteRecord permanently deletes a record from PocketBase
+func (c *Cleaner) deleteRecord(ctx context.Context, recordID string) error {
+ reqURL := fmt.Sprintf("%s/api/collections/%s/records/%s",
+ c.pb.baseURL, c.pb.targetColl, recordID)
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodDelete, reqURL, nil)
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Authorization", "Bearer "+c.pb.token)
+
+ resp, err := c.pb.http.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
+ return fmt.Errorf("delete failed with status %d", resp.StatusCode)
+ }
+
+ return nil
+}
+
+// GetRetentionStats returns statistics about records eligible for deletion
+func (c *Cleaner) GetRetentionStats(ctx context.Context) (eligible int, oldestDate string, err error) {
+ if c.cfg.RetentionDays <= 0 {
+ return 0, "", nil
+ }
+
+ if err := c.pb.ensureAuth(ctx); err != nil {
+ return 0, "", err
+ }
+
+ cutoff := time.Now().AddDate(0, 0, -c.cfg.RetentionDays)
+ cutoffStr := cutoff.Format("2006-01-02 00:00:00")
+ filter := url.QueryEscape(fmt.Sprintf("created<'%s'", cutoffStr))
+
+ reqURL := fmt.Sprintf("%s/api/collections/%s/records?filter=%s&perPage=1&sort=created",
+ c.pb.baseURL, c.pb.targetColl, filter)
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
+ if err != nil {
+ return 0, "", err
+ }
+ req.Header.Set("Authorization", "Bearer "+c.pb.token)
+
+ resp, err := c.pb.http.Do(req)
+ if err != nil {
+ return 0, "", err
+ }
+ defer resp.Body.Close()
+
+ var result struct {
+ Items []struct {
+ Created string `json:"created"`
+ } `json:"items"`
+ TotalItems int `json:"totalItems"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+ return 0, "", err
+ }
+
+ if len(result.Items) > 0 {
+ oldestDate = result.Items[0].Created[:10] // Just the date part
+ }
+
+ return result.TotalItems, oldestDate, nil
+}
diff --git a/dashboard.go b/dashboard.go
index b266fc9..5d9d903 100644
--- a/dashboard.go
+++ b/dashboard.go
@@ -933,18 +933,18 @@ func DashboardHTML() string {
/* Stat Cards Grid */
.stats-grid {
display: grid;
- grid-template-columns: repeat(3, 1fr);
+ grid-template-columns: repeat(4, 1fr);
gap: 20px;
margin-bottom: 32px;
}
- @media (max-width: 1200px) {
+ @media (max-width: 1400px) {
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
}
- @media (max-width: 640px) {
+ @media (max-width: 768px) {
.stats-grid {
grid-template-columns: 1fr;
}
@@ -1707,74 +1707,61 @@ func DashboardHTML() string {
white-space: nowrap;
}
- /* Podium Section */
- .podium-section {
- background: var(--bg-card);
- border: 1px solid var(--border-color);
- border-radius: 12px;
- padding: 24px;
- margin-bottom: 24px;
- text-align: center;
+ /* Mini Podium in Stat Card */
+ .podium-card {
+ min-width: 280px;
}
- .podium-section h2 {
- font-size: 18px;
- font-weight: 600;
- margin-bottom: 24px;
- color: var(--text-secondary);
- }
-
- .podium {
+ .mini-podium {
display: flex;
- justify-content: center;
- align-items: flex-end;
- gap: 20px;
- max-width: 600px;
- margin: 0 auto;
+ flex-direction: column;
+ gap: 8px;
+ margin-top: 8px;
}
- .podium-step {
+ .mini-podium-item {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 8px 12px;
+ border-radius: 8px;
+ background: var(--bg-body);
+ }
+
+ .mini-podium-item .medal {
+ font-size: 20px;
+ flex-shrink: 0;
+ }
+
+ .mini-podium-item .app-name {
flex: 1;
- text-align: center;
- }
-
- .podium-medal {
- font-size: 32px;
- margin-bottom: 8px;
- }
-
- .podium-app {
- font-size: 15px;
font-weight: 600;
- margin-bottom: 4px;
- word-break: break-word;
+ font-size: 14px;
color: var(--text-primary);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
}
- .podium-count {
+ .mini-podium-item .count {
font-size: 12px;
color: var(--text-secondary);
- margin-bottom: 12px;
+ flex-shrink: 0;
}
- .podium-bar {
- border-radius: 8px 8px 0 0;
- width: 100%;
+ .mini-podium-item.gold {
+ background: linear-gradient(135deg, rgba(255, 215, 0, 0.15) 0%, rgba(255, 215, 0, 0.05) 100%);
+ border-left: 3px solid #ffd700;
}
- .podium-step.first .podium-bar {
- height: 120px;
- background: linear-gradient(180deg, #ffd700 0%, #b8860b 100%);
+ .mini-podium-item.silver {
+ background: linear-gradient(135deg, rgba(192, 192, 192, 0.15) 0%, rgba(192, 192, 192, 0.05) 100%);
+ border-left: 3px solid #c0c0c0;
}
- .podium-step.second .podium-bar {
- height: 85px;
- background: linear-gradient(180deg, #c0c0c0 0%, #808080 100%);
- }
-
- .podium-step.third .podium-bar {
- height: 55px;
- background: linear-gradient(180deg, #cd7f32 0%, #8b4513 100%);
+ .mini-podium-item.bronze {
+ background: linear-gradient(135deg, rgba(205, 127, 50, 0.15) 0%, rgba(205, 127, 50, 0.05) 100%);
+ border-left: 3px solid #cd7f32;
}
.pagination {
@@ -2022,7 +2009,6 @@ func DashboardHTML() string {
@@ -2105,29 +2091,29 @@ func DashboardHTML() string {
-
Installations encountered errors
-
-
-
-
-
🏆 Most Popular Applications
-
-
-
🥈
-
-
-
-
-
+
+
+
+
-
-
-
🥉
-
-
-
-
-
+
+
+ 🥇
+ -
+ -
+
+
+ 🥈
+ -
+ -
+
+
+ 🥉
+ -
+ -
+
@@ -2461,16 +2447,21 @@ func DashboardHTML() string {
document.getElementById('error').style.display = 'none';
// Most Popular - update podium
+ function formatCompact(n) {
+ if (n >= 1000000) return (n/1000000).toFixed(1) + 'M';
+ if (n >= 1000) return (n/1000).toFixed(1) + 'k';
+ return n.toString();
+ }
if (data.top_apps && data.top_apps.length >= 3) {
document.getElementById('podium1App').textContent = data.top_apps[0].app;
- document.getElementById('podium1Count').textContent = data.top_apps[0].count.toLocaleString() + ' installs';
+ document.getElementById('podium1Count').textContent = formatCompact(data.top_apps[0].count);
document.getElementById('podium2App').textContent = data.top_apps[1].app;
- document.getElementById('podium2Count').textContent = data.top_apps[1].count.toLocaleString() + ' installs';
+ document.getElementById('podium2Count').textContent = formatCompact(data.top_apps[1].count);
document.getElementById('podium3App').textContent = data.top_apps[2].app;
- document.getElementById('podium3Count').textContent = data.top_apps[2].count.toLocaleString() + ' installs';
+ document.getElementById('podium3Count').textContent = formatCompact(data.top_apps[2].count);
} else if (data.top_apps && data.top_apps.length > 0) {
document.getElementById('podium1App').textContent = data.top_apps[0].app;
- document.getElementById('podium1Count').textContent = data.top_apps[0].count.toLocaleString() + ' installs';
+ document.getElementById('podium1Count').textContent = formatCompact(data.top_apps[0].count);
}
// Store all apps data for View All feature
diff --git a/docs/TOMS.md b/docs/TOMS.md
new file mode 100644
index 0000000..21a0e9b
--- /dev/null
+++ b/docs/TOMS.md
@@ -0,0 +1,166 @@
+# Technische und Organisatorische Maßnahmen (TOM)
+
+**gemäß Art. 32 DSGVO**
+
+---
+
+## 1. Vertraulichkeit (Art. 32 Abs. 1 lit. b DSGVO)
+
+### 1.1 Zutrittskontrolle
+| Maßnahme | Umsetzung | Status |
+|----------|-----------|--------|
+| Rechenzentrum | Hetzner Cloud (ISO 27001 zertifiziert) | ✅ |
+| Physischer Zugriff | Durch Hetzner gesichert (Biometrie, 24/7 Überwachung) | ✅ |
+
+### 1.2 Zugangskontrolle
+| Maßnahme | Umsetzung | Status |
+|----------|-----------|--------|
+| SSH-Zugang | Nur mit SSH-Keys, kein Passwort-Login | ✅ |
+| API-Authentifizierung | PocketBase Admin-Token erforderlich | ✅ |
+| Dashboard-Zugriff | Lesezugriff ohne Authentifizierung (nur aggregierte Daten) | ✅ |
+| Admin-Zugriff | Über Coolify mit 2FA | ✅ |
+
+### 1.3 Zugriffskontrolle
+| Maßnahme | Umsetzung | Status |
+|----------|-----------|--------|
+| Berechtigungskonzept | Minimalprinzip: Service hat nur Schreibrechte auf telemetry-Collection | ✅ |
+| API-Endpunkte | Telemetrie-Endpoint: Nur POST, Dashboard-API: Nur GET | ✅ |
+| Keine Root-Prozesse | Container läuft mit non-root User | ✅ |
+
+### 1.4 Trennungskontrolle
+| Maßnahme | Umsetzung | Status |
+|----------|-----------|--------|
+| Datentrennung | Separate Collections für ProxmoxVE/ProxmoxVED | ✅ |
+| Netzwerktrennung | Docker-Network-Isolation | ✅ |
+| Umgebungstrennung | Produktion getrennt von Entwicklung | ✅ |
+
+---
+
+## 2. Integrität (Art. 32 Abs. 1 lit. b DSGVO)
+
+### 2.1 Weitergabekontrolle
+| Maßnahme | Umsetzung | Status |
+|----------|-----------|--------|
+| Transportverschlüsselung | TLS 1.3 (HTTPS) | ✅ |
+| Interne Kommunikation | Docker-internes Netzwerk | ✅ |
+| Keine Drittland-Übermittlung | Server ausschließlich in Deutschland | ✅ |
+
+### 2.2 Eingabekontrolle
+| Maßnahme | Umsetzung | Status |
+|----------|-----------|--------|
+| Request-Validierung | Strikte JSON-Schema-Validierung | ✅ |
+| Max Body Size | 1024 Bytes (verhindert Oversized Payloads) | ✅ |
+| Fehlermeldungen | Max. 120 Zeichen (verhindert Log-Injection) | ✅ |
+| Audit-Logging | Fehlerhafte Anfragen werden geloggt (ohne IP) | ✅ |
+
+---
+
+## 3. Verfügbarkeit und Belastbarkeit (Art. 32 Abs. 1 lit. b/c DSGVO)
+
+### 3.1 Verfügbarkeitskontrolle
+| Maßnahme | Umsetzung | Status |
+|----------|-----------|--------|
+| Health-Checks | `/health`-Endpoint mit Docker HEALTHCHECK | ✅ |
+| Auto-Restart | Coolify startet Container bei Absturz neu | ✅ |
+| Rate Limiting | 60 Requests/Minute pro IP (DDoS-Schutz) | ✅ |
+| Timeout-Handling | 120s Timeout für Dashboard-Queries | ✅ |
+
+### 3.2 Wiederherstellbarkeit
+| Maßnahme | Umsetzung | Status |
+|----------|-----------|--------|
+| Datensicherung | PocketBase SQLite-Backups durch Coolify | ✅ |
+| Backup-Intervall | Täglich | ✅ |
+| Disaster Recovery | Daten können aus Backup wiederhergestellt werden | ✅ |
+
+---
+
+## 4. Verfahren zur regelmäßigen Überprüfung (Art. 32 Abs. 1 lit. d DSGVO)
+
+### 4.1 Datenschutz-Management
+| Maßnahme | Umsetzung | Status |
+|----------|-----------|--------|
+| VVT vorhanden | [docs/VVT.md](VVT.md) | ✅ |
+| Security Policy | [SECURITY.md](../SECURITY.md) | ✅ |
+| Löschkonzept | Automatische Löschung nach 365 Tagen | ✅ |
+
+### 4.2 Technische Prüfungen
+| Maßnahme | Intervall | Status |
+|----------|-----------|--------|
+| Dependency-Updates | Bei jedem Build (Go Modules) | ✅ |
+| Container-Updates | Alpine-Base regelmäßig aktualisiert | ✅ |
+| Code-Review | Alle Änderungen via Pull Request | ✅ |
+
+---
+
+## 5. Privacy by Design / Privacy by Default (Art. 25 DSGVO)
+
+### 5.1 Privacy by Design
+| Prinzip | Umsetzung | Status |
+|---------|-----------|--------|
+| Datenminimierung | Nur technisch notwendige Daten werden erhoben | ✅ |
+| Anonymität | Keine personenbezogenen Daten, anonyme Session-IDs | ✅ |
+| Keine IP-Speicherung | `ENABLE_REQUEST_LOGGING=false` | ✅ |
+
+### 5.2 Privacy by Default
+| Einstellung | Standard | Status |
+|-------------|----------|--------|
+| Telemetrie | Opt-In (Nutzer muss aktiv zustimmen) | ✅ |
+| Request-Logging | Deaktiviert | ✅ |
+| Datenweitergabe | Keine | ✅ |
+
+---
+
+## 6. Auftragsverarbeitung
+
+### 6.1 Dienstleister
+| Dienstleister | Funktion | Standort | Vertrag |
+|---------------|----------|----------|---------|
+| Hetzner Cloud | Infrastructure | Deutschland | AV-Vertrag vorhanden |
+| Coolify | Container-Orchestrierung | Self-Hosted | - |
+| GitHub | Source Code Hosting | USA | DPF-zertifiziert |
+
+### 6.2 Keine Weitergabe an Dritte
+Die Telemetriedaten werden **nicht** an externe Analysedienste, Werbepartner oder sonstige Dritte weitergegeben.
+
+---
+
+## 7. Technische Schutzmaßnahmen im Code
+
+```go
+// service.go - Security Headers
+w.Header().Set("X-Content-Type-Options", "nosniff")
+w.Header().Set("X-Frame-Options", "DENY")
+w.Header().Set("Referrer-Policy", "no-referrer")
+
+// Rate Limiting
+RateLimitRPM: 60 // Max 60 Requests pro Minute
+RateBurst: 20 // Burst-Limit
+MaxBodyBytes: 1024 // Max 1KB Request-Body
+
+// Keine IP-Speicherung
+EnableReqLogging: false
+```
+
+---
+
+## 8. Maßnahmen bei Datenschutzverletzungen
+
+| Schritt | Verantwortlich | Frist |
+|---------|----------------|-------|
+| Erkennung | Automatisch (Monitoring) oder via GitHub Issue | - |
+| Ersteinschätzung | Maintainer | 24 Stunden |
+| Meldung an Aufsichtsbehörde | N/A (keine personenbezogenen Daten) | - |
+| Benachrichtigung Betroffener | N/A (keine personenbezogenen Daten) | - |
+| Dokumentation | GitHub Security Advisory | 7 Tage |
+
+---
+
+## 9. Änderungshistorie
+
+| Datum | Version | Änderung | Autor |
+|-------|---------|----------|-------|
+| 2026-02-12 | 1.0 | Initiale Erstellung | Community Scripts Team |
+
+---
+
+*Diese Dokumentation wird bei wesentlichen Änderungen am Service aktualisiert.*
diff --git a/docs/VVT.md b/docs/VVT.md
new file mode 100644
index 0000000..3a68bb8
--- /dev/null
+++ b/docs/VVT.md
@@ -0,0 +1,146 @@
+# Verzeichnis von Verarbeitungstätigkeiten (VVT)
+
+**gemäß Art. 30 DSGVO**
+
+---
+
+## 1. Angaben zum Verantwortlichen
+
+| Feld | Wert |
+|------|------|
+| **Organisation** | Community Scripts (Open Source Projekt) |
+| **Projektname** | Telemetry Service |
+| **Repository** | https://github.com/community-scripts/telemetry-service |
+| **Kontakt** | Über GitHub Issues |
+
+---
+
+## 2. Zweck der Verarbeitung
+
+### 2.1 Beschreibung
+Der Telemetry Service sammelt **anonyme technische Nutzungsstatistiken** von den Proxmox VE Helper-Scripts. Diese Daten dienen ausschließlich der:
+
+- **Qualitätsverbesserung**: Identifikation von Scripts mit hohen Fehlerraten
+- **Priorisierung**: Erkennung der meistgenutzten Anwendungen
+- **Trendanalyse**: Verständnis der verwendeten Betriebssysteme und Ressourcenkonfigurationen
+
+### 2.2 Rechtsgrundlage
+**Art. 6 Abs. 1 lit. f DSGVO** (berechtigtes Interesse)
+
+Das berechtigte Interesse liegt in der Verbesserung der Open-Source-Software für die Community. Die Verarbeitung ist minimal-invasiv, da:
+- Keine personenbezogenen Daten erhoben werden
+- Keine IP-Adressen gespeichert werden
+- Die Datenübermittlung opt-in ist (Nutzer müssen aktiv zustimmen)
+
+---
+
+## 3. Kategorien betroffener Personen
+
+| Kategorie | Beschreibung |
+|-----------|--------------|
+| Nutzer der Helper-Scripts | Administratoren, die Proxmox VE Helper-Scripts ausführen und der Telemetrie zugestimmt haben |
+
+---
+
+## 4. Kategorien personenbezogener Daten
+
+### ⚠️ KEINE personenbezogenen Daten werden erhoben
+
+Die erhobenen Daten sind **ausschließlich technischer Natur** und lassen **keinen Rückschluss auf natürliche Personen** zu:
+
+| Datenfeld | Typ | Beschreibung | Personenbezug |
+|-----------|-----|--------------|---------------|
+| `random_id` | UUID | Zufällig generierte Session-ID (pro Installation neu) | ❌ Nein |
+| `type` | String | LXC, VM, Tool, Addon | ❌ Nein |
+| `nsapp` | String | Name der installierten Anwendung (z.B. "jellyfin") | ❌ Nein |
+| `status` | String | Erfolgreich / Fehlgeschlagen / Installierend | ❌ Nein |
+| `disk_size` | Integer | Festplattengröße in GB | ❌ Nein |
+| `core_count` | Integer | CPU-Kerne | ❌ Nein |
+| `ram_size` | Integer | RAM in MB | ❌ Nein |
+| `os_type` | String | Betriebssystem (debian, ubuntu, alpine) | ❌ Nein |
+| `os_version` | String | OS-Version (12, 24.04) | ❌ Nein |
+| `pve_version` | String | Proxmox VE Version | ❌ Nein |
+| `method` | String | Installationsmethode | ❌ Nein |
+| `error` | String | Fehlerbeschreibung (max. 120 Zeichen) | ❌ Nein |
+| `exit_code` | Integer | Exit-Code (0-255) | ❌ Nein |
+| `gpu_vendor` | String | GPU-Hersteller | ❌ Nein |
+| `cpu_vendor` | String | CPU-Hersteller | ❌ Nein |
+| `install_duration` | Integer | Installationsdauer in Sekunden | ❌ Nein |
+
+### Was wird NICHT erhoben:
+- ❌ IP-Adressen (Request-Logging deaktiviert)
+- ❌ Hostnamen oder Domainnamen
+- ❌ MAC-Adressen oder Seriennummern
+- ❌ Benutzernamen oder E-Mail-Adressen
+- ❌ Netzwerkkonfiguration
+- ❌ Standortdaten
+
+---
+
+## 5. Empfänger der Daten
+
+| Empfänger | Zweck | Rechtsgrundlage |
+|-----------|-------|-----------------|
+| PocketBase (selbst gehostet) | Speicherung der Telemetriedaten | Auftragsverarbeitung (gleicher Server) |
+| GitHub (für öffentliches Dashboard) | Aggregierte Statistiken | Art. 6 Abs. 1 lit. f (berechtigtes Interesse) |
+
+**Keine Weitergabe an Dritte.** Die Daten werden ausschließlich für die Verbesserung der Helper-Scripts verwendet.
+
+---
+
+## 6. Übermittlung in Drittländer
+
+| Drittland | Übermittlung | Garantien |
+|-----------|--------------|-----------|
+| USA | ❌ Nein | - |
+| Andere | ❌ Nein | - |
+
+Die Datenverarbeitung erfolgt **ausschließlich auf EU-Servern** (Hetzner Cloud, Deutschland).
+
+---
+
+## 7. Löschfristen
+
+| Datenkategorie | Löschfrist | Begründung |
+|----------------|------------|------------|
+| Telemetriedaten | **365 Tage** | Ausreichend für jährliche Trendanalysen |
+| Aggregierte Statistiken | Unbegrenzt | Keine personenbezogenen Daten |
+| Logs (falls aktiviert) | 7 Tage | Technische Fehlerbehebung |
+
+Die automatische Löschung wird durch den `cleanup`-Job im Service umgesetzt.
+
+---
+
+## 8. Technische und organisatorische Maßnahmen (TOM)
+
+Siehe separate Dokumentation: [TOMS.md](TOMS.md)
+
+**Zusammenfassung:**
+- ✅ Verschlüsselung in Transit (TLS 1.3)
+- ✅ Zugriffskontrolle (API-Token-basiert)
+- ✅ Rate Limiting (DDoS-Schutz)
+- ✅ Keine IP-Speicherung
+- ✅ Privacy by Design (anonyme Session-IDs)
+
+---
+
+## 9. Datenschutz-Folgenabschätzung (DSFA)
+
+Eine DSFA nach Art. 35 DSGVO ist **nicht erforderlich**, da:
+
+1. Keine personenbezogenen Daten verarbeitet werden
+2. Kein Profiling oder automatisierte Entscheidungsfindung stattfindet
+3. Keine besonderen Kategorien personenbezogener Daten (Art. 9 DSGVO) betroffen sind
+4. Die Verarbeitung kein hohes Risiko für die Rechte und Freiheiten natürlicher Personen darstellt
+
+---
+
+## 10. Änderungshistorie
+
+| Datum | Version | Änderung | Autor |
+|-------|---------|----------|-------|
+| 2026-02-12 | 1.0 | Initiale Erstellung | Community Scripts Team |
+
+---
+
+*Dieses Dokument wurde nach bestem Wissen und Gewissen erstellt. Bei Fragen oder Änderungswünschen kontaktieren Sie uns über GitHub Issues.*
diff --git a/service.go b/service.go
index c1ea192..518ce81 100644
--- a/service.go
+++ b/service.go
@@ -814,6 +814,16 @@ func main() {
}, pb)
alerter.Start()
+ // Initialize cleanup/retention job (GDPR Löschkonzept)
+ cleaner := NewCleaner(CleanupConfig{
+ Enabled: envBool("CLEANUP_ENABLED", true),
+ CheckInterval: time.Duration(envInt("CLEANUP_INTERVAL_MIN", 60)) * time.Minute,
+ StuckAfterHours: envInt("CLEANUP_STUCK_HOURS", 24),
+ RetentionEnabled: envBool("RETENTION_ENABLED", true),
+ RetentionDays: envInt("RETENTION_DAYS", 365),
+ }, pb)
+ cleaner.Start()
+
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {