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.
This commit is contained in:
sysops
2026-07-05 19:12:38 +02:00
parent bbefa838c3
commit 8564d7c11f
3 changed files with 59 additions and 4 deletions
+54 -4
View File
@@ -17,6 +17,7 @@ import (
var allowedServices = []string{ var allowedServices = []string{
"archivmail", "archivmail",
"archivmail-web", "archivmail-web",
"manticore",
"postgresql@17-main", "postgresql@17-main",
"postfix", "postfix",
"nginx", "nginx",
@@ -29,9 +30,57 @@ type ServiceStatus struct {
Sub string `json:"sub"` // running, dead, exited, ... Sub string `json:"sub"` // running, dead, exited, ...
Enabled string `json:"enabled"` // enabled, disabled, static, unknown Enabled string `json:"enabled"` // enabled, disabled, static, unknown
Description string `json:"description"` 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 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 { func isAllowedService(name string) bool {
for _, s := range allowedServices { for _, s := range allowedServices {
if s == name { if s == name {
@@ -41,7 +90,7 @@ func isAllowedService(name string) bool {
return false return false
} }
func systemctlShow(name string) ServiceStatus { func (s *Server) systemctlShow(name string) ServiceStatus {
svc := ServiceStatus{Name: name, DisplayName: name} svc := ServiceStatus{Name: name, DisplayName: name}
out, err := exec.Command("systemctl", "show", name+".service", out, err := exec.Command("systemctl", "show", name+".service",
"--property=ActiveState,SubState,UnitFileState,Description", "--property=ActiveState,SubState,UnitFileState,Description",
@@ -72,6 +121,7 @@ func systemctlShow(name string) ServiceStatus {
blocked := nftAPIBlocked() blocked := nftAPIBlocked()
svc.ExternalBlocked = &blocked svc.ExternalBlocked = &blocked
} }
svc.Version = s.serviceVersion(name)
return svc return svc
} }
@@ -87,7 +137,7 @@ func nftAPIBlocked() bool {
func (s *Server) handleListServices(w http.ResponseWriter, r *http.Request) { func (s *Server) handleListServices(w http.ResponseWriter, r *http.Request) {
result := make([]ServiceStatus, 0, len(allowedServices)) result := make([]ServiceStatus, 0, len(allowedServices))
for _, name := range allowedServices { for _, name := range allowedServices {
result = append(result, systemctlShow(name)) result = append(result, s.systemctlShow(name))
} }
writeJSON(w, http.StatusOK, result) writeJSON(w, http.StatusOK, result)
} }
@@ -142,7 +192,7 @@ func (s *Server) handleServiceAction(w http.ResponseWriter, r *http.Request) {
Detail: name, Detail: name,
Success: true, Success: true,
}) })
writeJSON(w, http.StatusOK, systemctlShow(name)) writeJSON(w, http.StatusOK, s.systemctlShow(name))
return return
} }
@@ -166,5 +216,5 @@ func (s *Server) handleServiceAction(w http.ResponseWriter, r *http.Request) {
Success: true, Success: true,
}) })
writeJSON(w, http.StatusOK, systemctlShow(name)) writeJSON(w, http.StatusOK, s.systemctlShow(name))
} }
@@ -66,6 +66,7 @@ export function ServicesTab({
<TableHead className="w-28">Status</TableHead> <TableHead className="w-28">Status</TableHead>
<TableHead className="w-24">Autostart</TableHead> <TableHead className="w-24">Autostart</TableHead>
<TableHead className="w-28">Externer Zugriff</TableHead> <TableHead className="w-28">Externer Zugriff</TableHead>
<TableHead className="w-56">Version</TableHead>
<TableHead>Beschreibung</TableHead> <TableHead>Beschreibung</TableHead>
{isSuperAdmin && <TableHead className="w-72 text-right">Aktionen</TableHead>} {isSuperAdmin && <TableHead className="w-72 text-right">Aktionen</TableHead>}
</TableRow> </TableRow>
@@ -115,6 +116,9 @@ export function ServicesTab({
<span className="text-xs text-muted-foreground"></span> <span className="text-xs text-muted-foreground"></span>
)} )}
</TableCell> </TableCell>
<TableCell className="text-xs font-mono text-muted-foreground truncate max-w-xs" title={svc.version || undefined}>
{svc.version || ""}
</TableCell>
<TableCell className="text-sm text-muted-foreground truncate max-w-xs"> <TableCell className="text-sm text-muted-foreground truncate max-w-xs">
{svc.description || ""} {svc.description || ""}
</TableCell> </TableCell>
+1
View File
@@ -43,6 +43,7 @@ export interface ServiceStatus {
sub: string; sub: string;
enabled: string; enabled: string;
description: string; description: string;
version?: string;
external_blocked?: boolean; external_blocked?: boolean;
} }