Files
archivmail/internal/api/admin_services_handlers.go
T
sysops 1d27dc2d8b refactor: große API-Handler-Dateien in fokussierte Module aufteilen
Reine Code-Verschiebung, keine Logikänderung. Betroffen:
- ldap_tenants.go (994 Zeilen) -> ldap_tenants.go (Routing) + ldap_handlers.go
  + tenant_handlers.go + tenant_domain_handlers.go + tenant_logo_handlers.go
  + tenant_helpers.go
- import_handlers.go (621 Zeilen) -> imap_handlers.go + pop3_handlers.go
  + import_helpers.go
- admin_handlers.go (702 Zeilen) -> admin_users_handlers.go +
  admin_status_handlers.go + admin_services_handlers.go +
  admin_security_handlers.go

Lokal kein Go-Build möglich — Verifikation manuell per Funktions- und
Import-Abgleich Alt/Neu (alle Symbole exakt einmal vorhanden). Build
muss vor Deploy auf 192.168.1.131/132 bestätigt werden.
2026-06-21 23:38:57 +02:00

171 lines
4.4 KiB
Go

package api
import (
"encoding/json"
"net/http"
"os/exec"
"strings"
"archivmail/internal/audit"
"archivmail/internal/auth"
"archivmail/internal/userstore"
)
// --- 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,
TenantID: sess.TenantID,
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,
TenantID: sess.TenantID,
IPAddress: s.remoteIP(r),
Detail: name,
Success: true,
})
writeJSON(w, http.StatusOK, systemctlShow(name))
}