Files
archivmail/internal/api/admin_services_handlers.go
sysops 8564d7c11f feat: Versions-Spalte im Dienste-Tab für Superadmin
Bislang war die einzige Möglichkeit, die laufende Manticore-/PostgreSQL-/
Postfix-/nginx-Version zu sehen, SSH + <binary> --version. serviceVersion()
löst das best-effort pro Dienst auf (archivmail/-web: appVersion-Konstante,
manticore: searchd --version, postgresql: psql --version, postfix: postconf
mail_version, nginx: nginx -v). Fehler werden verschluckt (leerer String),
eine unbekannte Version darf den Dienst-Status nicht auf "Fehler" kippen.

manticore war bisher gar nicht in der Dienste-Whitelist (allowedServices) —
jetzt ergänzt, damit es überhaupt in der Liste auftaucht und
start/stop/restart wie die anderen Dienste möglich ist.
2026-07-05 19:12:38 +02:00

221 lines
5.8 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",
"manticore",
"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"`
Version string `json:"version,omitempty"` // best-effort, empty if not determinable
ExternalBlocked *bool `json:"external_blocked,omitempty"` // only set for archivmail
}
// serviceVersion resolves the installed version of a service, best-effort.
// Superadmin-visible "which version is actually running" overview — before
// this, the only way to see e.g. the Manticore version after an upgrade
// (PROJ-67) was SSH + `searchd --version`. Errors are swallowed on purpose:
// an unknown version must never turn the whole services list red.
func (s *Server) serviceVersion(name string) string {
switch name {
case "archivmail", "archivmail-web":
return s.appVersion
case "manticore":
out, err := exec.Command("searchd", "--version").CombinedOutput()
if err != nil {
return ""
}
return firstLine(string(out))
case "postgresql@17-main":
out, err := exec.Command("psql", "--version").Output()
if err != nil {
return ""
}
return firstLine(string(out))
case "postfix":
out, err := exec.Command("postconf", "mail_version").Output()
if err != nil {
return ""
}
_, v, ok := strings.Cut(firstLine(string(out)), "=")
if !ok {
return ""
}
return strings.TrimSpace(v)
case "nginx":
out, err := exec.Command("nginx", "-v").CombinedOutput()
if err != nil {
return ""
}
return firstLine(string(out))
default:
return ""
}
}
func firstLine(s string) string {
line, _, _ := strings.Cut(s, "\n")
return strings.TrimSpace(line)
}
func isAllowedService(name string) bool {
for _, s := range allowedServices {
if s == name {
return true
}
}
return false
}
func (s *Server) 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
}
svc.Version = s.serviceVersion(name)
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, s.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, s.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, s.systemctlShow(name))
}