feat: Security Audit Auto-Fix Buttons + manuelle Dokumentation (docs/security-audit.md)
This commit is contained in:
@@ -0,0 +1,270 @@
|
|||||||
|
# Security Audit — archivmail
|
||||||
|
|
||||||
|
Dieses Dokument beschreibt alle Security-Checks die das System automatisch durchführt,
|
||||||
|
welche Werte als sicher gelten und wie Probleme manuell behoben werden können.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Übersicht der Checks
|
||||||
|
|
||||||
|
| Check | OK-Kriterium | Auto-Fix verfügbar |
|
||||||
|
|------------------------|----------------------------------------------|--------------------|
|
||||||
|
| Firewall (nftables) | Input-Chain policy: drop | Ja |
|
||||||
|
| Port 3000 (Next.js) | Nicht in Whitelist | Nein (via Firewall)|
|
||||||
|
| Port 8080 (Go Backend) | Nicht in Whitelist | Nein (via Firewall)|
|
||||||
|
| HTTPS (TLS) | Port 443 in Firewall + Zertifikat vorhanden | Nein (manuell) |
|
||||||
|
| SSH Root-Login | `prohibit-password` oder `no` | Ja |
|
||||||
|
| SSH Passwort-Auth | `PasswordAuthentication no` | Ja |
|
||||||
|
| Fail2ban | Dienst aktiv | Ja |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Firewall (nftables)
|
||||||
|
|
||||||
|
### Was wird geprüft
|
||||||
|
Ob `nft list ruleset` eine Input-Chain mit `policy drop` zurückgibt.
|
||||||
|
Whitelist-Modus: Nur explizit erlaubte Ports sind erreichbar.
|
||||||
|
|
||||||
|
### Erlaubte externe Ports
|
||||||
|
| Port | Dienst |
|
||||||
|
|------|-------------------|
|
||||||
|
| 22 | SSH |
|
||||||
|
| 80 | nginx (Web-UI) |
|
||||||
|
| 2525 | SMTP Einlieferung |
|
||||||
|
|
||||||
|
### Manuell prüfen
|
||||||
|
```bash
|
||||||
|
ssh root@192.168.1.131
|
||||||
|
|
||||||
|
# Aktuelle Regeln anzeigen
|
||||||
|
nft list ruleset
|
||||||
|
|
||||||
|
# Offene Ports prüfen
|
||||||
|
ss -tlnp
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manuell beheben
|
||||||
|
```bash
|
||||||
|
# Konfiguration neu laden
|
||||||
|
nft -f /etc/nftables.conf
|
||||||
|
|
||||||
|
# Beim Systemstart aktivieren
|
||||||
|
systemctl enable nftables
|
||||||
|
systemctl restart nftables
|
||||||
|
|
||||||
|
# Prüfen ob Regeln aktiv sind
|
||||||
|
nft list ruleset | grep "policy drop"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Konfigurationsdatei
|
||||||
|
`/etc/nftables.conf` — Änderungen hier sind persistent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Port 3000 / Port 8080
|
||||||
|
|
||||||
|
### Was wird geprüft
|
||||||
|
Ob diese Ports explizit in der Firewall-Whitelist stehen.
|
||||||
|
Beide Dienste sollen nur intern erreichbar sein (nginx proxied den Zugriff).
|
||||||
|
|
||||||
|
### Warum gefährlich
|
||||||
|
- **Port 3000**: Next.js Frontend — keine eigene Authentifizierung auf diesem Port
|
||||||
|
- **Port 8080**: Go Backend REST API — direkte API-Zugriffe ohne nginx möglich
|
||||||
|
|
||||||
|
### Manuell prüfen
|
||||||
|
```bash
|
||||||
|
# Lauscht Port 3000 auf allen Interfaces?
|
||||||
|
ss -tlnp | grep 3000
|
||||||
|
# => Wenn "0.0.0.0:3000" erscheint, ist er intern offen (Firewall muss blockieren)
|
||||||
|
|
||||||
|
# Ist Port von außen erreichbar?
|
||||||
|
nft list ruleset | grep "dport 3000"
|
||||||
|
# => Wenn keine Zeile erscheint: gut (policy drop blockiert ihn)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manuell beheben (falls Firewall fehlt)
|
||||||
|
```bash
|
||||||
|
# Firewall neu laden — blockiert automatisch 3000 und 8080
|
||||||
|
nft -f /etc/nftables.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. HTTPS (TLS)
|
||||||
|
|
||||||
|
### Was wird geprüft
|
||||||
|
Ob Port 443 in der Firewall freigegeben ist.
|
||||||
|
Aktuell läuft archivmail nur über HTTP (Port 80) — Verbindungen sind unverschlüsselt.
|
||||||
|
|
||||||
|
### Warum wichtig
|
||||||
|
Ohne TLS werden Passwörter und E-Mail-Inhalte im Klartext übertragen.
|
||||||
|
|
||||||
|
### Manuell einrichten (Let's Encrypt / certbot)
|
||||||
|
```bash
|
||||||
|
ssh root@192.168.1.131
|
||||||
|
|
||||||
|
# certbot installieren
|
||||||
|
apt-get install -y certbot python3-certbot-nginx
|
||||||
|
|
||||||
|
# Zertifikat ausstellen (Domain muss auf den Server zeigen)
|
||||||
|
certbot --nginx -d archivmail.example.com
|
||||||
|
|
||||||
|
# Firewall: Port 443 freigeben
|
||||||
|
# In /etc/nftables.conf folgende Zeile in chain input ergänzen:
|
||||||
|
# tcp dport 443 accept
|
||||||
|
nft -f /etc/nftables.conf
|
||||||
|
|
||||||
|
# Automatische Erneuerung prüfen
|
||||||
|
certbot renew --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
### nginx nach certbot
|
||||||
|
certbot passt die nginx-Konfiguration automatisch an und fügt SSL + Redirect HTTP→HTTPS hinzu.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. SSH Root-Login
|
||||||
|
|
||||||
|
### Was wird geprüft
|
||||||
|
Den Wert von `PermitRootLogin` in `/etc/ssh/sshd_config`.
|
||||||
|
|
||||||
|
| Wert | Bewertung |
|
||||||
|
|--------------------|-----------|
|
||||||
|
| `no` | OK |
|
||||||
|
| `prohibit-password`| OK (Key-Auth für root erlaubt) |
|
||||||
|
| `yes` | Warnung |
|
||||||
|
| nicht gesetzt | Warnung (Default ist prohibit-password) |
|
||||||
|
|
||||||
|
### Manuell prüfen
|
||||||
|
```bash
|
||||||
|
grep -i "PermitRootLogin" /etc/ssh/sshd_config
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manuell beheben
|
||||||
|
```bash
|
||||||
|
# Datei bearbeiten
|
||||||
|
nano /etc/ssh/sshd_config
|
||||||
|
|
||||||
|
# Zeile setzen oder ändern:
|
||||||
|
PermitRootLogin prohibit-password
|
||||||
|
|
||||||
|
# SSH-Dienst neu starten
|
||||||
|
systemctl restart ssh
|
||||||
|
|
||||||
|
# Prüfen
|
||||||
|
sshd -T | grep permitrootlogin
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. SSH Passwort-Authentifizierung
|
||||||
|
|
||||||
|
### Was wird geprüft
|
||||||
|
Den Wert von `PasswordAuthentication` in `/etc/ssh/sshd_config`.
|
||||||
|
Empfehlung: Nur SSH-Key-Authentifizierung erlauben.
|
||||||
|
|
||||||
|
### Manuell prüfen
|
||||||
|
```bash
|
||||||
|
grep -i "PasswordAuthentication" /etc/ssh/sshd_config
|
||||||
|
sshd -T | grep passwordauthentication
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manuell beheben
|
||||||
|
```bash
|
||||||
|
# Sicherstellen dass ein SSH-Key hinterlegt ist, BEVOR Passwort-Auth deaktiviert wird
|
||||||
|
cat ~/.ssh/authorized_keys # muss mindestens einen Eintrag enthalten
|
||||||
|
|
||||||
|
# Dann in /etc/ssh/sshd_config:
|
||||||
|
PasswordAuthentication no
|
||||||
|
|
||||||
|
# SSH neu starten
|
||||||
|
systemctl restart ssh
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Warnung:** Passwort-Auth nur deaktivieren wenn ein SSH-Key in `~/.ssh/authorized_keys` vorhanden ist.
|
||||||
|
> Sonst ist kein Login mehr möglich.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Fail2ban
|
||||||
|
|
||||||
|
### Was wird geprüft
|
||||||
|
Ob der `fail2ban`-Systemd-Dienst aktiv ist.
|
||||||
|
|
||||||
|
### Was ist Fail2ban
|
||||||
|
Fail2ban überwacht Log-Dateien und blockiert IPs automatisch bei zu vielen fehlgeschlagenen Login-Versuchen (Brute-Force-Schutz).
|
||||||
|
|
||||||
|
### Manuell prüfen
|
||||||
|
```bash
|
||||||
|
systemctl status fail2ban
|
||||||
|
|
||||||
|
# Aktive Jails anzeigen
|
||||||
|
fail2ban-client status
|
||||||
|
|
||||||
|
# SSH-Jail prüfen
|
||||||
|
fail2ban-client status sshd
|
||||||
|
|
||||||
|
# Gesperrte IPs anzeigen
|
||||||
|
fail2ban-client status sshd | grep "Banned IP"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manuell installieren und konfigurieren
|
||||||
|
```bash
|
||||||
|
apt-get install -y fail2ban
|
||||||
|
|
||||||
|
# Konfiguration erstellen
|
||||||
|
cat > /etc/fail2ban/jail.local << 'EOF'
|
||||||
|
[sshd]
|
||||||
|
enabled = true
|
||||||
|
maxretry = 5
|
||||||
|
bantime = 3600
|
||||||
|
findtime = 600
|
||||||
|
|
||||||
|
[nginx-http-auth]
|
||||||
|
enabled = true
|
||||||
|
EOF
|
||||||
|
|
||||||
|
systemctl enable fail2ban
|
||||||
|
systemctl start fail2ban
|
||||||
|
|
||||||
|
# Prüfen
|
||||||
|
fail2ban-client status
|
||||||
|
```
|
||||||
|
|
||||||
|
### Gesperrte IP manuell entsperren
|
||||||
|
```bash
|
||||||
|
fail2ban-client set sshd unbanip <IP-Adresse>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Kompletter manueller Audit (alle Checks auf einmal)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh root@192.168.1.131
|
||||||
|
|
||||||
|
echo "=== Firewall ==="
|
||||||
|
nft list ruleset | grep -E "policy|dport"
|
||||||
|
|
||||||
|
echo "=== Offene Ports ==="
|
||||||
|
ss -tlnp
|
||||||
|
|
||||||
|
echo "=== SSH Config ==="
|
||||||
|
sshd -T | grep -E "permitrootlogin|passwordauthentication"
|
||||||
|
|
||||||
|
echo "=== Fail2ban ==="
|
||||||
|
systemctl is-active fail2ban
|
||||||
|
fail2ban-client status 2>/dev/null || echo "nicht installiert"
|
||||||
|
|
||||||
|
echo "=== nftables Dienst ==="
|
||||||
|
systemctl is-enabled nftables
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Weiterführende Ressourcen
|
||||||
|
|
||||||
|
- [Debian SSH Hardening](https://wiki.debian.org/SSH)
|
||||||
|
- [nftables Dokumentation](https://wiki.nftables.org/)
|
||||||
|
- [Fail2ban Dokumentation](https://www.fail2ban.org/wiki/index.php/MANUAL_0_8)
|
||||||
|
- [Let's Encrypt / certbot](https://certbot.eff.org/)
|
||||||
@@ -106,6 +106,7 @@ func (s *Server) routes() {
|
|||||||
|
|
||||||
s.mux.HandleFunc("GET /api/admin/system/stats", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleSystemStats)))
|
s.mux.HandleFunc("GET /api/admin/system/stats", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleSystemStats)))
|
||||||
s.mux.HandleFunc("GET /api/admin/security/audit", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleSecurityAudit)))
|
s.mux.HandleFunc("GET /api/admin/security/audit", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleSecurityAudit)))
|
||||||
|
s.mux.HandleFunc("POST /api/admin/security/fix", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleSecurityFix)))
|
||||||
|
|
||||||
// Export routes
|
// Export routes
|
||||||
s.mux.HandleFunc("GET /api/export/pdf/{id}", s.authMiddleware(s.requireMailAccess(s.handleExportPDF)))
|
s.mux.HandleFunc("GET /api/export/pdf/{id}", s.authMiddleware(s.requireMailAccess(s.handleExportPDF)))
|
||||||
@@ -1598,3 +1599,123 @@ func (s *Server) handleSecurityAudit(w http.ResponseWriter, r *http.Request) {
|
|||||||
"run_at": time.Now().UTC().Format(time.RFC3339),
|
"run_at": time.Now().UTC().Format(time.RFC3339),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Security Fix ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// allowedFixActions is a strict whitelist — only these actions may be executed.
|
||||||
|
var allowedFixActions = map[string]bool{
|
||||||
|
"install_fail2ban": true,
|
||||||
|
"enable_firewall": true,
|
||||||
|
"fix_ssh_password_auth": true,
|
||||||
|
"fix_ssh_root_login": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSecurityFix(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body struct {
|
||||||
|
Action string `json:"action"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !allowedFixActions[body.Action] {
|
||||||
|
writeError(w, http.StatusBadRequest, "unknown action: "+body.Action)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := r.Context()
|
||||||
|
var msg string
|
||||||
|
var fixErr error
|
||||||
|
|
||||||
|
switch body.Action {
|
||||||
|
|
||||||
|
case "install_fail2ban":
|
||||||
|
// Install fail2ban if not present
|
||||||
|
if _, err := exec.LookPath("fail2ban-client"); err != nil {
|
||||||
|
out, err := exec.CommandContext(ctx, "apt-get", "install", "-y", "fail2ban").CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "apt-get install fail2ban: "+string(out))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Write a minimal jail.local if not already present
|
||||||
|
jailPath := "/etc/fail2ban/jail.local"
|
||||||
|
if _, err := os.Stat(jailPath); os.IsNotExist(err) {
|
||||||
|
jailConf := "[sshd]\nenabled = true\nmaxretry = 5\nbantime = 3600\nfindtime = 600\n"
|
||||||
|
if err := os.WriteFile(jailPath, []byte(jailConf), 0644); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "could not write jail.local: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Enable and start
|
||||||
|
exec.CommandContext(ctx, "systemctl", "enable", "fail2ban").Run()
|
||||||
|
out, err := exec.CommandContext(ctx, "systemctl", "restart", "fail2ban").CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
fixErr = fmt.Errorf("systemctl restart fail2ban: %s", string(out))
|
||||||
|
} else {
|
||||||
|
msg = "Fail2ban installiert, SSH-Jail aktiviert und Dienst gestartet."
|
||||||
|
}
|
||||||
|
|
||||||
|
case "enable_firewall":
|
||||||
|
// Reload rules from /etc/nftables.conf and enable service
|
||||||
|
out, err := exec.CommandContext(ctx, "nft", "-f", "/etc/nftables.conf").CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "nft -f /etc/nftables.conf: "+string(out))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
exec.CommandContext(ctx, "systemctl", "enable", "nftables").Run()
|
||||||
|
msg = "nftables-Regeln neu geladen und Dienst aktiviert."
|
||||||
|
|
||||||
|
case "fix_ssh_password_auth":
|
||||||
|
fixErr = sshConfigSet("PasswordAuthentication", "no")
|
||||||
|
if fixErr == nil {
|
||||||
|
out, err := exec.CommandContext(ctx, "systemctl", "restart", "ssh").CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
fixErr = fmt.Errorf("systemctl restart ssh: %s", string(out))
|
||||||
|
} else {
|
||||||
|
msg = "PasswordAuthentication auf 'no' gesetzt, SSH neu gestartet."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case "fix_ssh_root_login":
|
||||||
|
fixErr = sshConfigSet("PermitRootLogin", "prohibit-password")
|
||||||
|
if fixErr == nil {
|
||||||
|
out, err := exec.CommandContext(ctx, "systemctl", "restart", "ssh").CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
fixErr = fmt.Errorf("systemctl restart ssh: %s", string(out))
|
||||||
|
} else {
|
||||||
|
msg = "PermitRootLogin auf 'prohibit-password' gesetzt, SSH neu gestartet."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if fixErr != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, fixErr.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"message": msg})
|
||||||
|
}
|
||||||
|
|
||||||
|
// sshConfigSet sets or replaces a directive in /etc/ssh/sshd_config.
|
||||||
|
// Commented-out lines are left untouched; the active directive is updated or appended.
|
||||||
|
func sshConfigSet(key, value string) error {
|
||||||
|
const path = "/etc/ssh/sshd_config"
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read sshd_config: %w", err)
|
||||||
|
}
|
||||||
|
lines := strings.Split(string(data), "\n")
|
||||||
|
keyLower := strings.ToLower(key)
|
||||||
|
found := false
|
||||||
|
for i, l := range lines {
|
||||||
|
tl := strings.ToLower(strings.TrimSpace(l))
|
||||||
|
if strings.HasPrefix(tl, keyLower) && !strings.HasPrefix(strings.TrimSpace(l), "#") {
|
||||||
|
lines[i] = key + " " + value
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
lines = append(lines, key+" "+value)
|
||||||
|
}
|
||||||
|
return os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0644)
|
||||||
|
}
|
||||||
|
|||||||
+100
-40
@@ -18,6 +18,7 @@ import {
|
|||||||
uploadMailFiles,
|
uploadMailFiles,
|
||||||
getUploadProgress,
|
getUploadProgress,
|
||||||
getSecurityAudit,
|
getSecurityAudit,
|
||||||
|
fixSecurityIssue,
|
||||||
type User,
|
type User,
|
||||||
type AuditEntry,
|
type AuditEntry,
|
||||||
type SMTPStatus,
|
type SMTPStatus,
|
||||||
@@ -126,6 +127,8 @@ export default function AdminPage() {
|
|||||||
const [securityAudit, setSecurityAudit] = useState<SecurityAuditResult | null>(null);
|
const [securityAudit, setSecurityAudit] = useState<SecurityAuditResult | null>(null);
|
||||||
const [securityLoading, setSecurityLoading] = useState(false);
|
const [securityLoading, setSecurityLoading] = useState(false);
|
||||||
const [securityError, setSecurityError] = useState("");
|
const [securityError, setSecurityError] = useState("");
|
||||||
|
const [fixLoading, setFixLoading] = useState<string | null>(null);
|
||||||
|
const [fixMessage, setFixMessage] = useState("");
|
||||||
|
|
||||||
// Upload state
|
// Upload state
|
||||||
const [uploadDragging, setUploadDragging] = useState(false);
|
const [uploadDragging, setUploadDragging] = useState(false);
|
||||||
@@ -352,6 +355,7 @@ export default function AdminPage() {
|
|||||||
async function runSecurityAudit() {
|
async function runSecurityAudit() {
|
||||||
setSecurityLoading(true);
|
setSecurityLoading(true);
|
||||||
setSecurityError("");
|
setSecurityError("");
|
||||||
|
setFixMessage("");
|
||||||
try {
|
try {
|
||||||
const result = await getSecurityAudit();
|
const result = await getSecurityAudit();
|
||||||
setSecurityAudit(result);
|
setSecurityAudit(result);
|
||||||
@@ -362,6 +366,22 @@ export default function AdminPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function runFix(action: string) {
|
||||||
|
setFixLoading(action);
|
||||||
|
setFixMessage("");
|
||||||
|
setSecurityError("");
|
||||||
|
try {
|
||||||
|
const res = await fixSecurityIssue(action);
|
||||||
|
setFixMessage(res.message);
|
||||||
|
// Re-run audit automatically to reflect new state
|
||||||
|
await runSecurityAudit();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setSecurityError(e instanceof Error ? e.message : "Fix fehlgeschlagen.");
|
||||||
|
} finally {
|
||||||
|
setFixLoading(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const auditTotalPages = Math.ceil(auditTotal / AUDIT_PAGE_SIZE);
|
const auditTotalPages = Math.ceil(auditTotal / AUDIT_PAGE_SIZE);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1215,7 +1235,7 @@ export default function AdminPage() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={runSecurityAudit} disabled={securityLoading} size="sm">
|
<Button onClick={runSecurityAudit} disabled={securityLoading || fixLoading !== null} size="sm">
|
||||||
{securityLoading ? "Prüfe..." : "Jetzt prüfen"}
|
{securityLoading ? "Prüfe..." : "Jetzt prüfen"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1226,6 +1246,12 @@ export default function AdminPage() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{fixMessage && (
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription className="text-green-700">{fixMessage}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
{!securityAudit && !securityLoading && !securityError && (
|
{!securityAudit && !securityLoading && !securityError && (
|
||||||
<div className="rounded-lg border border-dashed p-10 text-center text-sm text-muted-foreground">
|
<div className="rounded-lg border border-dashed p-10 text-center text-sm text-muted-foreground">
|
||||||
Klicke auf “Jetzt prüfen” um den Security-Audit zu starten.
|
Klicke auf “Jetzt prüfen” um den Security-Audit zu starten.
|
||||||
@@ -1240,47 +1266,81 @@ export default function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{securityAudit && !securityLoading && (
|
{securityAudit && !securityLoading && (() => {
|
||||||
<div className="space-y-2">
|
// Map check names to fix actions
|
||||||
{securityAudit.checks.map((check: SecurityCheck, i: number) => (
|
const fixActions: Record<string, { action: string; label: string }> = {
|
||||||
<Card key={i}>
|
"Fail2ban": { action: "install_fail2ban", label: "Installieren & aktivieren" },
|
||||||
<CardContent className="p-4 flex items-start gap-3">
|
"Firewall (nftables)": { action: "enable_firewall", label: "Firewall aktivieren" },
|
||||||
<span className={`mt-0.5 h-2.5 w-2.5 flex-shrink-0 rounded-full ${
|
"SSH Passwort-Auth": { action: "fix_ssh_password_auth", label: "Deaktivieren" },
|
||||||
check.status === "ok" ? "bg-green-500" :
|
"SSH Root-Login": { action: "fix_ssh_root_login", label: "Auf prohibit-password setzen" },
|
||||||
check.status === "warning" ? "bg-yellow-400" :
|
};
|
||||||
"bg-red-500"
|
|
||||||
}`} />
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm font-medium">{check.name}</span>
|
|
||||||
<Badge
|
|
||||||
variant={check.status === "ok" ? "default" : check.status === "warning" ? "secondary" : "destructive"}
|
|
||||||
className={`text-xs ${check.status === "ok" ? "bg-green-100 text-green-800 hover:bg-green-100" : check.status === "warning" ? "bg-yellow-100 text-yellow-800 hover:bg-yellow-100" : ""}`}
|
|
||||||
>
|
|
||||||
{check.status === "ok" ? "OK" : check.status === "warning" ? "Warnung" : "Fehler"}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground mt-0.5 font-mono">{check.message}</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Summary */}
|
return (
|
||||||
<div className="mt-4 grid grid-cols-3 gap-3 text-center text-sm">
|
<div className="space-y-2">
|
||||||
{[
|
{securityAudit.checks.map((check: SecurityCheck, i: number) => {
|
||||||
{ label: "OK", color: "bg-green-50 text-green-700", count: securityAudit.checks.filter((c: SecurityCheck) => c.status === "ok").length },
|
const fix = check.status !== "ok" ? fixActions[check.name] : undefined;
|
||||||
{ label: "Warnungen", color: "bg-yellow-50 text-yellow-700", count: securityAudit.checks.filter((c: SecurityCheck) => c.status === "warning").length },
|
return (
|
||||||
{ label: "Fehler", color: "bg-red-50 text-red-700", count: securityAudit.checks.filter((c: SecurityCheck) => c.status === "error").length },
|
<Card key={i}>
|
||||||
].map((s) => (
|
<CardContent className="p-4 flex items-start gap-3">
|
||||||
<div key={s.label} className={`rounded p-3 ${s.color}`}>
|
<span className={`mt-1 h-2.5 w-2.5 flex-shrink-0 rounded-full ${
|
||||||
<p className="text-2xl font-bold">{s.count}</p>
|
check.status === "ok" ? "bg-green-500" :
|
||||||
<p className="text-xs">{s.label}</p>
|
check.status === "warning" ? "bg-yellow-400" : "bg-red-500"
|
||||||
</div>
|
}`} />
|
||||||
))}
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-sm font-medium">{check.name}</span>
|
||||||
|
<Badge
|
||||||
|
variant={check.status === "ok" ? "default" : check.status === "warning" ? "secondary" : "destructive"}
|
||||||
|
className={`text-xs ${check.status === "ok" ? "bg-green-100 text-green-800 hover:bg-green-100" : check.status === "warning" ? "bg-yellow-100 text-yellow-800 hover:bg-yellow-100" : ""}`}
|
||||||
|
>
|
||||||
|
{check.status === "ok" ? "OK" : check.status === "warning" ? "Warnung" : "Fehler"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5 font-mono">{check.message}</p>
|
||||||
|
</div>
|
||||||
|
{fix && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="flex-shrink-0 text-xs"
|
||||||
|
disabled={fixLoading !== null}
|
||||||
|
onClick={() => runFix(fix.action)}
|
||||||
|
>
|
||||||
|
{fixLoading === fix.action ? "Wird behoben..." : fix.label}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{check.name === "HTTPS (TLS)" && check.status !== "ok" && (
|
||||||
|
<a
|
||||||
|
href="https://certbot.eff.org/instructions?os=debianbuster&tab=standard"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<Button size="sm" variant="outline" className="flex-shrink-0 text-xs">
|
||||||
|
Anleitung
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
<div className="mt-4 grid grid-cols-3 gap-3 text-center text-sm">
|
||||||
|
{[
|
||||||
|
{ label: "OK", color: "bg-green-50 text-green-700", count: securityAudit.checks.filter((c: SecurityCheck) => c.status === "ok").length },
|
||||||
|
{ label: "Warnungen", color: "bg-yellow-50 text-yellow-700", count: securityAudit.checks.filter((c: SecurityCheck) => c.status === "warning").length },
|
||||||
|
{ label: "Fehler", color: "bg-red-50 text-red-700", count: securityAudit.checks.filter((c: SecurityCheck) => c.status === "error").length },
|
||||||
|
].map((s) => (
|
||||||
|
<div key={s.label} className={`rounded p-3 ${s.color}`}>
|
||||||
|
<p className="text-2xl font-bold">{s.count}</p>
|
||||||
|
<p className="text-xs">{s.label}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
)}
|
})()}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="modules" className="mt-4">
|
<TabsContent value="modules" className="mt-4">
|
||||||
|
|||||||
@@ -520,3 +520,10 @@ export interface SecurityAuditResult {
|
|||||||
export async function getSecurityAudit(): Promise<SecurityAuditResult> {
|
export async function getSecurityAudit(): Promise<SecurityAuditResult> {
|
||||||
return request<SecurityAuditResult>("/api/admin/security/audit");
|
return request<SecurityAuditResult>("/api/admin/security/audit");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fixSecurityIssue(action: string): Promise<{ message: string }> {
|
||||||
|
return request<{ message: string }>("/api/admin/security/fix", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ action }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user