fix: integrate podium into stats row as compact card

This commit is contained in:
CanbiZ (MickLesk)
2026-02-12 11:18:00 +01:00
parent e69c6a4bd0
commit bb795abf2c
6 changed files with 690 additions and 83 deletions
+106
View File
@@ -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*
+191 -3
View File
@@ -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
}
+71 -80
View File
@@ -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 {
<select id="repoFilter" class="custom-select" onchange="refreshData()">
<option value="ProxmoxVE" selected>ProxmoxVE</option>
<option value="ProxmoxVED">ProxmoxVED</option>
<option value="Proxmox VE">Proxmox VE (Legacy)</option>
<option value="external">External</option>
<option value="all">All Sources</option>
</select>
@@ -2105,29 +2091,29 @@ func DashboardHTML() string {
<div class="stat-card-value" id="failedCount">-</div>
<div class="stat-card-subtitle">Installations encountered errors</div>
</div>
</div>
<!-- Most Popular Podium -->
<div class="podium-section">
<h2>🏆 Most Popular Applications</h2>
<div class="podium">
<div class="podium-step second">
<div class="podium-medal">🥈</div>
<div class="podium-app" id="podium2App">-</div>
<div class="podium-count" id="podium2Count">-</div>
<div class="podium-bar"></div>
<!-- Most Popular Card -->
<div class="stat-card podium-card">
<div class="stat-card-header">
<span class="stat-card-label">Most Popular</span>
<div class="stat-card-icon" style="font-size: 20px;">🏆</div>
</div>
<div class="podium-step first">
<div class="podium-medal">🥇</div>
<div class="podium-app" id="podium1App">-</div>
<div class="podium-count" id="podium1Count">-</div>
<div class="podium-bar"></div>
</div>
<div class="podium-step third">
<div class="podium-medal">🥉</div>
<div class="podium-app" id="podium3App">-</div>
<div class="podium-count" id="podium3Count">-</div>
<div class="podium-bar"></div>
<div class="mini-podium">
<div class="mini-podium-item gold">
<span class="medal">🥇</span>
<span class="app-name" id="podium1App">-</span>
<span class="count" id="podium1Count">-</span>
</div>
<div class="mini-podium-item silver">
<span class="medal">🥈</span>
<span class="app-name" id="podium2App">-</span>
<span class="count" id="podium2Count">-</span>
</div>
<div class="mini-podium-item bronze">
<span class="medal">🥉</span>
<span class="app-name" id="podium3App">-</span>
<span class="count" id="podium3Count">-</span>
</div>
</div>
</div>
</div>
@@ -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
+166
View File
@@ -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.*
+146
View File
@@ -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.*
+10
View File
@@ -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) {