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.
171 lines
4.4 KiB
Go
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))
|
|
}
|