2bab61209c
Go-Modul in go.mod und allen 45 Go-Dateien umbenannt.
698 lines
22 KiB
Go
698 lines
22 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"archivmail/internal/audit"
|
|
"archivmail/internal/auth"
|
|
"archivmail/internal/userstore"
|
|
)
|
|
|
|
func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) {
|
|
tenantID := tenantFromCtx(r.Context())
|
|
|
|
var (
|
|
users []*userstore.User
|
|
err error
|
|
)
|
|
if tenantID != nil {
|
|
users, err = s.users.ListByTenant(r.Context(), *tenantID)
|
|
} else {
|
|
users, err = s.users.List("")
|
|
}
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list users")
|
|
return
|
|
}
|
|
|
|
type userResp struct {
|
|
ID int64 `json:"id"`
|
|
Username string `json:"username"`
|
|
Email string `json:"email"`
|
|
Role string `json:"role"`
|
|
Active bool `json:"active"`
|
|
TenantID *int64 `json:"tenant_id,omitempty"`
|
|
}
|
|
|
|
resp := make([]userResp, 0, len(users))
|
|
for _, u := range users {
|
|
resp = append(resp, userResp{
|
|
ID: u.ID,
|
|
Username: u.Username,
|
|
Email: u.Email,
|
|
Role: u.Role,
|
|
Active: u.Active,
|
|
TenantID: u.TenantID,
|
|
})
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Username string `json:"username"`
|
|
Email string `json:"email"`
|
|
Password string `json:"password"`
|
|
Role string `json:"role"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
|
|
// SEC-01: Privilege escalation check — caller must not assign a role
|
|
// at or above their own level.
|
|
sess := sessionFromCtx(r.Context())
|
|
if roleLevel(req.Role) >= roleLevel(sess.Role) {
|
|
writeError(w, http.StatusForbidden, "insufficient privileges to assign this role")
|
|
return
|
|
}
|
|
|
|
// SEC-02: Tenant isolation — non-superadmin users can only create users
|
|
// within their own tenant.
|
|
var tenantID *int64
|
|
if sess.TenantID != nil {
|
|
tenantID = sess.TenantID
|
|
}
|
|
|
|
// PROJ-29: Enforce max_users quota before creating a new user.
|
|
if tenantID != nil && s.tenantStore != nil {
|
|
quota, qErr := s.tenantStore.GetQuota(r.Context(), *tenantID)
|
|
if qErr == nil && quota.MaxUsers != nil {
|
|
usage, uErr := s.tenantStore.GetUsage(r.Context(), *tenantID)
|
|
if uErr == nil && int(usage.UserCount) >= *quota.MaxUsers {
|
|
writeError(w, http.StatusPaymentRequired, "user quota exceeded")
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
user, err := s.users.Create(userstore.CreateUserRequest{
|
|
Username: req.Username,
|
|
Email: req.Email,
|
|
Password: req.Password,
|
|
Role: req.Role,
|
|
TenantID: tenantID,
|
|
})
|
|
if err != nil {
|
|
s.logger.Error("create user failed", "err", err)
|
|
writeError(w, http.StatusBadRequest, "user creation failed")
|
|
return
|
|
}
|
|
|
|
s.audlog.Log(audit.Entry{
|
|
EventType: audit.EventUserMgmt,
|
|
Username: sess.Username,
|
|
IPAddress: s.remoteIP(r),
|
|
Detail: "created user: " + user.Username,
|
|
Success: true,
|
|
})
|
|
|
|
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
|
"id": user.ID,
|
|
"username": user.Username,
|
|
"email": user.Email,
|
|
"role": user.Role,
|
|
"active": user.Active,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid user id")
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Email *string `json:"email"`
|
|
Role *string `json:"role"`
|
|
Active *bool `json:"active"`
|
|
Password *string `json:"password"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
|
|
sess := sessionFromCtx(r.Context())
|
|
|
|
// SEC-02: Tenant isolation — load target user and verify same tenant.
|
|
target, err := s.users.GetByID(id)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "user not found")
|
|
return
|
|
}
|
|
if sess.TenantID != nil {
|
|
if target.TenantID == nil || *target.TenantID != *sess.TenantID {
|
|
writeError(w, http.StatusForbidden, "access denied")
|
|
return
|
|
}
|
|
}
|
|
|
|
// SEC-01: Privilege escalation check — caller must not assign a role
|
|
// at or above their own level, and must not modify users at or above
|
|
// their own level.
|
|
if roleLevel(target.Role) >= roleLevel(sess.Role) {
|
|
writeError(w, http.StatusForbidden, "insufficient privileges to modify this user")
|
|
return
|
|
}
|
|
if req.Role != nil && roleLevel(*req.Role) >= roleLevel(sess.Role) {
|
|
writeError(w, http.StatusForbidden, "insufficient privileges to assign this role")
|
|
return
|
|
}
|
|
|
|
updated, err := s.users.Update(id, userstore.UpdateUserRequest{
|
|
Email: req.Email,
|
|
Role: req.Role,
|
|
Active: req.Active,
|
|
Password: req.Password,
|
|
})
|
|
if err != nil {
|
|
s.logger.Error("update user failed", "err", err)
|
|
writeError(w, http.StatusBadRequest, "user update failed")
|
|
return
|
|
}
|
|
|
|
s.audlog.Log(audit.Entry{
|
|
EventType: audit.EventUserMgmt,
|
|
Username: sess.Username,
|
|
IPAddress: s.remoteIP(r),
|
|
Detail: fmt.Sprintf("updated user %d", id),
|
|
Success: true,
|
|
})
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"id": updated.ID,
|
|
"username": updated.Username,
|
|
"email": updated.Email,
|
|
"role": updated.Role,
|
|
"active": updated.Active,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
|
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid user id")
|
|
return
|
|
}
|
|
|
|
// Fetch user info before deletion for audit log and IMAP cleanup
|
|
target, err := s.users.GetByID(id)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "user not found")
|
|
return
|
|
}
|
|
|
|
// SEC-02: Tenant isolation — domain_admin can only delete users in their own tenant.
|
|
sess := sessionFromCtx(r.Context())
|
|
if sess.TenantID != nil {
|
|
if target.TenantID == nil || *target.TenantID != *sess.TenantID {
|
|
writeError(w, http.StatusForbidden, "access denied")
|
|
return
|
|
}
|
|
}
|
|
|
|
// SEC-01: Cannot delete users at or above own privilege level.
|
|
if roleLevel(target.Role) >= roleLevel(sess.Role) {
|
|
writeError(w, http.StatusForbidden, "insufficient privileges to delete this user")
|
|
return
|
|
}
|
|
|
|
if err := s.users.DeleteSafe(id); err != nil {
|
|
if err.Error() == "userstore: cannot delete last admin" {
|
|
writeError(w, http.StatusConflict, "cannot delete the last active admin")
|
|
return
|
|
}
|
|
s.logger.Error("delete user failed", "err", err)
|
|
writeError(w, http.StatusInternalServerError, "user deletion failed")
|
|
return
|
|
}
|
|
|
|
// Remove all IMAP accounts that belonged to this user
|
|
imapDeleted := 0
|
|
if s.imapStore != nil {
|
|
if n, err := s.imapStore.DeleteByOwner(r.Context(), target.Username); err != nil {
|
|
s.logger.Warn("delete user: could not remove IMAP accounts", "user", target.Username, "err", err)
|
|
} else {
|
|
imapDeleted = n
|
|
}
|
|
}
|
|
|
|
s.audlog.Log(audit.Entry{
|
|
EventType: audit.EventUserMgmt,
|
|
Username: sess.Username,
|
|
IPAddress: s.remoteIP(r),
|
|
Detail: fmt.Sprintf(
|
|
"deleted user %d (%s, role=%s); %d IMAP account(s) removed; emails retained per GoBD",
|
|
id, target.Username, target.Role, imapDeleted,
|
|
),
|
|
Success: true,
|
|
})
|
|
|
|
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
|
}
|
|
|
|
func (s *Server) handleSMTPStatus(w http.ResponseWriter, r *http.Request) {
|
|
sess := sessionFromCtx(r.Context())
|
|
tenantID := tenantFromCtx(r.Context())
|
|
|
|
// domain_admin: return only their tenant's email statistics (no global daemon info)
|
|
if sess != nil && !auth.HasRole(sess.Role, userstore.RoleSuperAdmin) {
|
|
stats, err := s.store.StatsByTenant(r.Context(), tenantID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to read stats")
|
|
return
|
|
}
|
|
domains := []string{}
|
|
if tenantID != nil && s.tenantStore != nil {
|
|
if dd, derr := s.tenantStore.ListDomains(r.Context(), *tenantID); derr == nil {
|
|
for _, d := range dd {
|
|
domains = append(domains, d.Domain)
|
|
}
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"enabled": true,
|
|
"tenant_only": true,
|
|
"domains": domains,
|
|
"total_mails": stats["count"],
|
|
"total_bytes": stats["total_size"],
|
|
})
|
|
return
|
|
}
|
|
|
|
// superadmin: global daemon status
|
|
if s.smtpDaemon == nil {
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{"enabled": false, "running": false})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, s.smtpDaemon.Status())
|
|
}
|
|
|
|
func (s *Server) handleStorageStats(w http.ResponseWriter, r *http.Request) {
|
|
tenantID := tenantFromCtx(r.Context())
|
|
stats, err := s.store.StatsByTenant(r.Context(), tenantID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to read storage stats")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"total_mails": stats["count"],
|
|
"total_bytes": stats["total_size"],
|
|
})
|
|
}
|
|
|
|
// --- Service management ---
|
|
|
|
// allowedServices is the whitelist of systemd service names the admin may control.
|
|
var allowedServices = []string{
|
|
"archivmail",
|
|
"archivmail-web",
|
|
"postgresql@17-main",
|
|
"postfix",
|
|
"nginx",
|
|
}
|
|
|
|
type ServiceStatus struct {
|
|
Name string `json:"name"`
|
|
DisplayName string `json:"display_name"`
|
|
Active string `json:"active"` // active, inactive, failed, unknown
|
|
Sub string `json:"sub"` // running, dead, exited, ...
|
|
Enabled string `json:"enabled"` // enabled, disabled, static, unknown
|
|
Description string `json:"description"`
|
|
ExternalBlocked *bool `json:"external_blocked,omitempty"` // only set for archivmail
|
|
}
|
|
|
|
func isAllowedService(name string) bool {
|
|
for _, s := range allowedServices {
|
|
if s == name {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func systemctlShow(name string) ServiceStatus {
|
|
svc := ServiceStatus{Name: name, DisplayName: name}
|
|
out, err := exec.Command("systemctl", "show", name+".service",
|
|
"--property=ActiveState,SubState,UnitFileState,Description",
|
|
"--no-pager").Output()
|
|
if err != nil {
|
|
svc.Active = "unknown"
|
|
svc.Sub = ""
|
|
svc.Enabled = "unknown"
|
|
} else {
|
|
for _, line := range strings.Split(string(out), "\n") {
|
|
k, v, ok := strings.Cut(line, "=")
|
|
if !ok {
|
|
continue
|
|
}
|
|
switch k {
|
|
case "ActiveState":
|
|
svc.Active = v
|
|
case "SubState":
|
|
svc.Sub = v
|
|
case "UnitFileState":
|
|
svc.Enabled = v
|
|
case "Description":
|
|
svc.Description = v
|
|
}
|
|
}
|
|
}
|
|
if name == "archivmail" {
|
|
blocked := nftAPIBlocked()
|
|
svc.ExternalBlocked = &blocked
|
|
}
|
|
return svc
|
|
}
|
|
|
|
// nftAPIBlocked reports whether external access to port 8080 is currently blocked.
|
|
func nftAPIBlocked() bool {
|
|
out, err := exec.Command("sudo", "/usr/local/sbin/archivmail-nft", "status").Output()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return strings.TrimSpace(string(out)) == "blocked"
|
|
}
|
|
|
|
func (s *Server) handleListServices(w http.ResponseWriter, r *http.Request) {
|
|
result := make([]ServiceStatus, 0, len(allowedServices))
|
|
for _, name := range allowedServices {
|
|
result = append(result, systemctlShow(name))
|
|
}
|
|
writeJSON(w, http.StatusOK, result)
|
|
}
|
|
|
|
func (s *Server) handleServiceAction(w http.ResponseWriter, r *http.Request) {
|
|
// Only superadmin may start/stop/restart services
|
|
sess := sessionFromCtx(r.Context())
|
|
if sess == nil || !auth.HasRole(sess.Role, userstore.RoleSuperAdmin) {
|
|
writeError(w, http.StatusForbidden, "superadmin required")
|
|
return
|
|
}
|
|
|
|
name := r.PathValue("name")
|
|
if !isAllowedService(name) {
|
|
writeError(w, http.StatusBadRequest, "unknown service")
|
|
return
|
|
}
|
|
|
|
var body struct {
|
|
Action string `json:"action"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request")
|
|
return
|
|
}
|
|
|
|
allowedActions := map[string]bool{
|
|
"start": true, "stop": true, "restart": true,
|
|
"enable": true, "disable": true,
|
|
}
|
|
nftActions := map[string]string{
|
|
"block_external": "block",
|
|
"allow_external": "unblock",
|
|
}
|
|
|
|
if nftArg, isNft := nftActions[body.Action]; isNft {
|
|
if name != "archivmail" {
|
|
writeError(w, http.StatusBadRequest, "external access control only available for archivmail")
|
|
return
|
|
}
|
|
out, err := exec.Command("sudo", "/usr/local/sbin/archivmail-nft", nftArg).CombinedOutput()
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, strings.TrimSpace(string(out)))
|
|
return
|
|
}
|
|
sess := sessionFromCtx(r.Context())
|
|
s.audlog.Log(audit.Entry{
|
|
EventType: "service." + body.Action,
|
|
Username: sess.Username,
|
|
IPAddress: s.remoteIP(r),
|
|
Detail: name,
|
|
Success: true,
|
|
})
|
|
writeJSON(w, http.StatusOK, systemctlShow(name))
|
|
return
|
|
}
|
|
|
|
if !allowedActions[body.Action] {
|
|
writeError(w, http.StatusBadRequest, "unknown action")
|
|
return
|
|
}
|
|
|
|
out, err := exec.Command("sudo", "/usr/bin/systemctl", body.Action, name+".service").CombinedOutput()
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, strings.TrimSpace(string(out)))
|
|
return
|
|
}
|
|
|
|
s.audlog.Log(audit.Entry{
|
|
EventType: "service." + body.Action,
|
|
Username: sess.Username,
|
|
IPAddress: s.remoteIP(r),
|
|
Detail: name,
|
|
Success: true,
|
|
})
|
|
|
|
writeJSON(w, http.StatusOK, systemctlShow(name))
|
|
}
|
|
|
|
// ── Security Audit ──────────────────────────────────────────────────────────
|
|
|
|
type securityCheck struct {
|
|
Name string `json:"name"`
|
|
Status string `json:"status"` // "ok" | "warning" | "error"
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
func (s *Server) handleSecurityAudit(w http.ResponseWriter, r *http.Request) {
|
|
var checks []securityCheck
|
|
|
|
// 1. Firewall (nftables) aktiv?
|
|
nftOut, err := exec.CommandContext(r.Context(), "nft", "list", "ruleset").Output()
|
|
nftStr := string(nftOut)
|
|
firewallActive := err == nil
|
|
|
|
if !firewallActive {
|
|
checks = append(checks, securityCheck{
|
|
Name: "Firewall (nftables)",
|
|
Status: "error",
|
|
Message: "nft konnte nicht ausgeführt werden — Firewall möglicherweise inaktiv",
|
|
})
|
|
} else if strings.Contains(nftStr, "policy drop") {
|
|
checks = append(checks, securityCheck{
|
|
Name: "Firewall (nftables)",
|
|
Status: "ok",
|
|
Message: "Aktiv — Input-Chain policy: drop (Whitelist-Modus)",
|
|
})
|
|
} else {
|
|
checks = append(checks, securityCheck{
|
|
Name: "Firewall (nftables)",
|
|
Status: "warning",
|
|
Message: "nftables aktiv, aber Input-Chain policy ist nicht 'drop'",
|
|
})
|
|
}
|
|
|
|
// 2. Port 3000 (Next.js) extern erreichbar?
|
|
if !firewallActive {
|
|
checks = append(checks, securityCheck{Name: "Port 3000 (Next.js)", Status: "error", Message: "Keine Firewall aktiv — Port möglicherweise extern erreichbar"})
|
|
} else if strings.Contains(nftStr, "dport 3000") {
|
|
checks = append(checks, securityCheck{Name: "Port 3000 (Next.js)", Status: "warning", Message: "Port 3000 explizit in Firewall-Regeln — prüfen ob gewollt"})
|
|
} else {
|
|
checks = append(checks, securityCheck{Name: "Port 3000 (Next.js)", Status: "ok", Message: "Blockiert (nicht in Whitelist)"})
|
|
}
|
|
|
|
// 3. Port 8080 (Go Backend) extern erreichbar?
|
|
if !firewallActive {
|
|
checks = append(checks, securityCheck{Name: "Port 8080 (Go Backend)", Status: "error", Message: "Keine Firewall aktiv — Port möglicherweise extern erreichbar"})
|
|
} else if strings.Contains(nftStr, "dport 8080") {
|
|
checks = append(checks, securityCheck{Name: "Port 8080 (Go Backend)", Status: "warning", Message: "Port 8080 explizit in Firewall-Regeln — prüfen ob gewollt"})
|
|
} else {
|
|
checks = append(checks, securityCheck{Name: "Port 8080 (Go Backend)", Status: "ok", Message: "Blockiert (nicht in Whitelist)"})
|
|
}
|
|
|
|
// 4. HTTPS aktiv?
|
|
if firewallActive && strings.Contains(nftStr, "dport 443") {
|
|
checks = append(checks, securityCheck{Name: "HTTPS (TLS)", Status: "ok", Message: "Port 443 in Firewall freigegeben"})
|
|
} else {
|
|
checks = append(checks, securityCheck{Name: "HTTPS (TLS)", Status: "warning", Message: "Kein HTTPS — Verbindungen unverschlüsselt (certbot empfohlen)"})
|
|
}
|
|
|
|
// 5. SSH PermitRootLogin + PasswordAuthentication
|
|
sshConf, err := os.ReadFile("/etc/ssh/sshd_config")
|
|
if err == nil {
|
|
lines := strings.Split(string(sshConf), "\n")
|
|
rootLogin := ""
|
|
passAuth := ""
|
|
for _, l := range lines {
|
|
tl := strings.ToLower(strings.TrimSpace(l))
|
|
if strings.HasPrefix(tl, "permitrootlogin") && !strings.HasPrefix(tl, "#") {
|
|
rootLogin = tl
|
|
}
|
|
if strings.HasPrefix(tl, "passwordauthentication") && !strings.HasPrefix(tl, "#") {
|
|
passAuth = tl
|
|
}
|
|
}
|
|
if strings.Contains(rootLogin, "no") || strings.Contains(rootLogin, "prohibit-password") {
|
|
checks = append(checks, securityCheck{Name: "SSH Root-Login", Status: "ok", Message: rootLogin})
|
|
} else if rootLogin == "" {
|
|
checks = append(checks, securityCheck{Name: "SSH Root-Login", Status: "warning", Message: "Nicht explizit gesetzt (Standard: prohibit-password)"})
|
|
} else {
|
|
checks = append(checks, securityCheck{Name: "SSH Root-Login", Status: "warning", Message: rootLogin + " — Passwort-Login für root möglich"})
|
|
}
|
|
if strings.Contains(passAuth, "no") {
|
|
checks = append(checks, securityCheck{Name: "SSH Passwort-Auth", Status: "ok", Message: "Nur Key-basierte Authentifizierung"})
|
|
} else if passAuth == "" {
|
|
checks = append(checks, securityCheck{Name: "SSH Passwort-Auth", Status: "warning", Message: "Nicht explizit deaktiviert — SSH-Keys empfohlen"})
|
|
} else {
|
|
checks = append(checks, securityCheck{Name: "SSH Passwort-Auth", Status: "warning", Message: passAuth + " — Brute-Force-Risiko"})
|
|
}
|
|
} else {
|
|
checks = append(checks, securityCheck{Name: "SSH Konfiguration", Status: "warning", Message: "/etc/ssh/sshd_config nicht lesbar"})
|
|
}
|
|
|
|
// 6. Fail2ban
|
|
f2bOut, err := exec.CommandContext(r.Context(), "systemctl", "is-active", "fail2ban").Output()
|
|
if err == nil && strings.TrimSpace(string(f2bOut)) == "active" {
|
|
checks = append(checks, securityCheck{Name: "Fail2ban", Status: "ok", Message: "Aktiv"})
|
|
} else {
|
|
checks = append(checks, securityCheck{Name: "Fail2ban", Status: "warning", Message: "Nicht aktiv — kein Brute-Force-Schutz (apt install fail2ban)"})
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"checks": checks,
|
|
"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 {
|
|
s.logger.Error("could not write jail.local", "err", err)
|
|
writeError(w, http.StatusInternalServerError, "security config update failed")
|
|
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)
|
|
}
|