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.
221 lines
5.8 KiB
Go
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))
|
|
}
|