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)) }