v9.6.0
release-tag / release-image (push) Successful in 2m28s

This commit is contained in:
2026-09-01 22:46:20 +02:00
parent 63d514e50d
commit f3a687543c
18 changed files with 706 additions and 9 deletions
+2
View File
@@ -26,4 +26,6 @@ ALLOW_HOST_PERMISSION_MANAGEMENT=false
HOST_SECURITY_ENABLED=false
ALLOW_HOST_SECURITY_CHANGES=false
ALLOW_HOST_PACKAGE_MANAGEMENT=false
# Optional on Arch: non-root account used by paru/yay for AUR updates.
HOST_AUR_USER=
HOST_SECURITY_HOST_PID=1
+1 -1
View File
@@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1.7
FROM golang:1.25-alpine AS build
WORKDIR /src
ARG VERSION=dev
ARG VERSION=9.6.0
ARG COMMIT=dev
ARG BUILD_DATE=unknown
COPY go.mod ./
+1 -1
View File
@@ -1,5 +1,5 @@
.PHONY: fmt tidy verify test build docker run clean
VERSION ?= dev
VERSION ?= 9.6.0
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo dev)
BUILD_DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
LDFLAGS := -s -w -X git.send.nrw/sendnrw/dockwatch/internal/buildinfo.Version=$(VERSION) -X git.send.nrw/sendnrw/dockwatch/internal/buildinfo.Commit=$(COMMIT) -X git.send.nrw/sendnrw/dockwatch/internal/buildinfo.Date=$(BUILD_DATE)
+38 -1
View File
@@ -1,4 +1,4 @@
# Dockwatch v9.5.3
# Dockwatch v9.6.0
> Go module: `git.send.nrw/sendnrw/dockwatch`
@@ -6,6 +6,18 @@ Dockwatch is a single-binary Go control plane for Docker Compose, Docker resourc
The same binary runs as `standalone`, `master` or `agent`. SQLite uses `modernc.org/sqlite`, so the application itself builds with `CGO_ENABLED=0`.
## v9.6.0 System-Updates
- neuer Admin-Bereich **System-Updates** für den lokalen Host und ausgewählte Master/Agent-Nodes
- Update-Erkennung und Installation über apt, dnf/yum, pacman sowie weiterhin zypper/apk als bereits unterstützte Paketmanagerfamilien
- Arch-Abfragen nutzen bevorzugt `checkupdates` aus `pacman-contrib`, damit keine isolierte `pacman -Sy`-Synchronisierung und damit kein Partial-Upgrade-Risiko entsteht
- Arch-Systemupdates werden ausschließlich als vollständiges `pacman -Syu --noconfirm` ausgeführt
- AUR-Unterstützung erkennt `paru` (bevorzugt) oder `yay`; `HOST_AUR_USER` legt den Nicht-root-Benutzer fest, unter dem AUR-Abfrage/Build läuft
- AUR-Installation fordert die Paket-Eskalation des Helpers non-interaktiv an (`--sudoflags=-n`); Hosts müssen diesen Benutzer entsprechend für den Paketmanager autorisieren
- neue Master/Agent-Endpunkte für Update-Liste, Paketmetadaten-Refresh und Installation; alle Mutationen bleiben Admin-only und laufen durch Origin-Schutz/Audit-Logging
- Browser kann weiterhin keine freien Paketnamen oder Shell-Kommandos einreichen, sondern nur die vordefinierten Scopes `system`, `aur` oder `all`
- statische Assets verwenden `v=9.6.0` zur sicheren Cache-Aktualisierung
## v9.5.3 deutsche Weboberfläche
- die Weboberfläche ist nun durchgängig deutsch beschriftet; interne API-/Compose-Werte bleiben unverändert
@@ -256,6 +268,8 @@ services:
HOST_SECURITY_ENABLED: "true"
ALLOW_HOST_SECURITY_CHANGES: "true"
ALLOW_HOST_PACKAGE_MANAGEMENT: "true"
# Arch/AUR optional, e.g. deploy
HOST_AUR_USER: ""
HOST_SECURITY_HOST_PID: "1"
volumes:
- /:/host:rw
@@ -267,6 +281,28 @@ Because this mode grants Dockwatch broad host-administration capability, use OID
The same security APIs are available through the existing master/agent relay, so each agent can audit/manage **its own host** when that agent has been configured with the appropriate host-security capability. A master does not silently inherit host privileges on an agent.
#### System package updates
The **System-Updates** page uses the same selected environment as Docker/Host Security. Read-only update discovery requires the host-security namespace executor. Installing updates additionally requires `ALLOW_HOST_SECURITY_CHANGES=true` and `ALLOW_HOST_PACKAGE_MANAGEMENT=true`.
Supported update strategies:
- **apt**: simulate `dist-upgrade` for discovery; refresh with `apt-get update`; install with non-interactive `apt-get dist-upgrade -y --no-remove`
- **dnf**: `check-update`; install with `dnf upgrade --refresh -y`
- **yum**: `check-update`; install with `yum update -y`
- **pacman**: prefer `checkupdates` for safe discovery; install official packages only through full `pacman -Syu --noconfirm`
- **AUR**: detect `paru` or `yay`, query with `-Qua`, update AUR-only with `-Sua`, or combine repository+AUR updates with `-Syu`
- **zypper/apk** remain supported because the Host Security package installer already supports those families
For AUR, configure a real non-root host account:
```yaml
environment:
HOST_AUR_USER: deploy
```
That user must be able to run the helper and perform its package-manager escalation without an interactive password prompt. Dockwatch passes `--sudoflags=-n`; it never stores or submits a sudo password.
### Monitoring
Probe types:
@@ -462,6 +498,7 @@ ALLOW_HOST_PERMISSION_MANAGEMENT=false
HOST_SECURITY_ENABLED=false
ALLOW_HOST_SECURITY_CHANGES=false
ALLOW_HOST_PACKAGE_MANAGEMENT=false
HOST_AUR_USER=
HOST_SECURITY_HOST_PID=1
```
+1
View File
@@ -56,6 +56,7 @@ func main() {
HostRoot: cfg.HostRoot,
DataDir: cfg.DataDir,
HostPID: cfg.HostSecurityPID,
AURUser: cfg.HostAURUser,
})
ms.SetEventSink(func(ctx context.Context, ev monitor.Event) {
nt.Broadcast(ctx, notify.Message{Title: "Monitor " + ev.To + ": " + ev.Name, Body: ev.Target + " changed from " + ev.From + " to " + ev.To + ". " + ev.Check.Message, Status: ev.To, MonitorID: ev.MonitorID})
+1
View File
@@ -23,6 +23,7 @@ services:
HOST_SECURITY_ENABLED: "${HOST_SECURITY_ENABLED:-false}"
ALLOW_HOST_SECURITY_CHANGES: "${ALLOW_HOST_SECURITY_CHANGES:-false}"
ALLOW_HOST_PACKAGE_MANAGEMENT: "${ALLOW_HOST_PACKAGE_MANAGEMENT:-false}"
HOST_AUR_USER: "${HOST_AUR_USER:-}"
HOST_SECURITY_HOST_PID: "${HOST_SECURITY_HOST_PID:-1}"
volumes:
- ./data:/data
+2
View File
@@ -14,6 +14,8 @@ services:
HOST_SECURITY_ENABLED: "false"
ALLOW_HOST_SECURITY_CHANGES: "false"
ALLOW_HOST_PACKAGE_MANAGEMENT: "false"
# Optional for Arch/AUR agents, e.g. "deploy".
HOST_AUR_USER: ""
HOST_SECURITY_HOST_PID: "1"
volumes:
- ./agent-data:/data
@@ -10,5 +10,6 @@ services:
HOST_SECURITY_ENABLED: "true"
ALLOW_HOST_SECURITY_CHANGES: "false"
ALLOW_HOST_PACKAGE_MANAGEMENT: "false"
HOST_AUR_USER: ""
volumes:
- /:/host:ro
@@ -13,6 +13,8 @@ services:
HOST_SECURITY_ENABLED: "true"
ALLOW_HOST_SECURITY_CHANGES: "true"
ALLOW_HOST_PACKAGE_MANAGEMENT: "true"
# Arch only: set to the non-root user that owns paru/yay configuration/cache.
HOST_AUR_USER: ""
HOST_SECURITY_HOST_PID: "1"
volumes:
- /:/host:rw
+1 -1
View File
@@ -4,7 +4,7 @@ import "runtime"
// Values may be overridden at build time with -ldflags -X.
var (
Version = "0.9.0"
Version = "9.6.0"
Commit = "dev"
Date = "unknown"
)
+2 -1
View File
@@ -26,7 +26,7 @@ type Config struct {
ListenAddr, BaseURL, DataDir, StacksDir, AppSecret string
SecureCookies, AuthDisabled bool
OIDCIssuer, OIDCClientID, OIDCClientSecret, OIDCRedirectURL, OIDCAdminGroup, OIDCOperatorGroup string
AgentToken, HostRoot string
AgentToken, HostRoot, HostAURUser string
AllowHostUserManagement, AllowHostPermissionManagement bool
HostSecurityEnabled, AllowHostSecurityChanges, AllowHostPackageManagement bool
HostSecurityPID int
@@ -95,6 +95,7 @@ func Load() (Config, error) {
OIDCOperatorGroup: env("OIDC_OPERATOR_GROUP", "dockwatch-operators"),
AgentToken: os.Getenv("AGENT_TOKEN"),
HostRoot: cleanOptionalPath(os.Getenv("HOST_ROOT")),
HostAURUser: strings.TrimSpace(os.Getenv("HOST_AUR_USER")),
AllowHostUserManagement: allowHostUserManagement,
AllowHostPermissionManagement: allowHostPermissionManagement,
HostSecurityEnabled: hostSecurityEnabled,
+477
View File
@@ -0,0 +1,477 @@
package hostsecurity
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"regexp"
"sort"
"strings"
"time"
)
type PackageUpdate struct {
Name string `json:"name"`
Arch string `json:"arch,omitempty"`
CurrentVersion string `json:"current_version,omitempty"`
NewVersion string `json:"new_version,omitempty"`
Repository string `json:"repository,omitempty"`
Source string `json:"source"`
}
type PackageReport struct {
OS OSInfo `json:"os"`
PackageManager string `json:"package_manager"`
AURHelper string `json:"aur_helper,omitempty"`
AURUser string `json:"aur_user,omitempty"`
CanInspect bool `json:"can_inspect"`
CanManage bool `json:"can_manage"`
Updates []PackageUpdate `json:"updates"`
AURUpdates []PackageUpdate `json:"aur_updates,omitempty"`
Warnings []string `json:"warnings,omitempty"`
CheckedAt time.Time `json:"checked_at"`
}
type PackageUpgradeInput struct {
Scope string `json:"scope"`
}
var (
packageArrowRE = regexp.MustCompile(`^([^\s]+)\s+([^\s]+)\s+->\s+([^\s]+)$`)
apkUpdateRE = regexp.MustCompile(`^(.+)-([0-9][^\s]*)\s+<\s+([^\s]+)$`)
ansiEscapeRE = regexp.MustCompile(`\x1b\[[0-9;]*[A-Za-z]`)
)
func (s *Service) requirePackageInspection(ctx context.Context) error {
if !s.cfg.Enabled {
return errors.New("host security layer is disabled")
}
caps := s.capabilities(ctx)
if !caps.ExecutorAvailable || !caps.TargetVerified {
if caps.Reason != "" {
return errors.New(caps.Reason)
}
return errors.New("host namespace executor unavailable")
}
if s.packageManager() == "" {
return errors.New("no supported host package manager detected")
}
return nil
}
func (s *Service) PackageUpdates(ctx context.Context) (PackageReport, error) {
s.mu.Lock()
defer s.mu.Unlock()
if err := s.requirePackageInspection(ctx); err != nil {
return PackageReport{}, err
}
pm := s.packageManager()
report := PackageReport{
OS: s.osInfo(),
PackageManager: pm,
AURHelper: s.aurHelper(),
AURUser: s.cfg.AURUser,
CanInspect: true,
CanManage: false,
Updates: []PackageUpdate{},
CheckedAt: time.Now().UTC(),
}
caps := s.capabilities(ctx)
report.CanManage = caps.AllowChanges && caps.AllowPackageManagement && caps.ExecutorAvailable && caps.TargetVerified && caps.HostRootWritable
x, cancel := context.WithTimeout(ctx, 90*time.Second)
defer cancel()
var out string
var code int
var err error
switch pm {
case "apt":
out, err = s.hostCommand(x, nil, "apt-get", "-s", "dist-upgrade")
if err == nil {
report.Updates = parseAPTUpdates(out)
}
case "dnf", "yum":
out, code, err = s.hostCommandExit(x, nil, pm, "check-update", "-q")
if err == nil && code != 0 && code != 100 {
err = fmt.Errorf("%s check-update exited with status %d: %s", pm, code, strings.TrimSpace(out))
}
if err == nil {
report.Updates = parseRPMUpdates(out)
}
case "zypper":
out, err = s.hostCommand(x, nil, "zypper", "--non-interactive", "list-updates")
if err == nil {
report.Updates = parseZypperUpdates(out)
}
case "apk":
out, err = s.hostCommand(x, nil, "apk", "version", "-l", "<")
if err == nil {
report.Updates = parseAPKUpdates(out)
}
case "pacman":
if s.hostBinaryExists("/usr/bin/checkupdates", "/bin/checkupdates") {
out, code, err = s.hostCommandExit(x, nil, "checkupdates", "--nocolor")
if err == nil && code != 0 && code != 2 {
err = fmt.Errorf("checkupdates exited with status %d: %s", code, strings.TrimSpace(out))
}
} else {
out, code, err = s.hostCommandExit(x, nil, "pacman", "-Qu")
if err == nil && code != 0 && code != 1 {
err = fmt.Errorf("pacman -Qu exited with status %d: %s", code, strings.TrimSpace(out))
}
report.Warnings = append(report.Warnings, "pacman-contrib/checkupdates ist nicht installiert; die Anzeige basiert auf der zuletzt synchronisierten Pacman-Datenbank. Dockwatch führt absichtlich kein isoliertes pacman -Sy aus.")
}
if err == nil {
report.Updates = parseArrowUpdates(out, "repo")
}
}
if err != nil {
return PackageReport{}, err
}
if pm == "pacman" && report.AURHelper != "" {
if !s.hostBinaryExists("/usr/bin/runuser", "/bin/runuser", "/usr/sbin/runuser") {
report.Warnings = append(report.Warnings, "AUR-Helper erkannt, aber runuser fehlt auf dem Host; AUR-Aktionen sind nicht verfügbar.")
} else if strings.TrimSpace(s.cfg.AURUser) == "" {
report.Warnings = append(report.Warnings, "AUR-Helper erkannt, aber HOST_AUR_USER ist nicht gesetzt. AUR-Updates werden deshalb nicht abgefragt oder installiert.")
} else if _, err := s.aurUser(); err != nil {
report.Warnings = append(report.Warnings, "AUR-Benutzer ist nicht verwendbar: "+err.Error())
} else {
aurOut, aurCode, aurErr := s.hostCommandExit(x, nil, s.aurCommand(report.AURHelper, "-Qua")...)
if aurErr == nil && aurCode != 0 && aurCode != 1 {
aurErr = fmt.Errorf("%s -Qua exited with status %d: %s", report.AURHelper, aurCode, strings.TrimSpace(aurOut))
}
if aurErr != nil {
report.Warnings = append(report.Warnings, "AUR-Updateprüfung fehlgeschlagen: "+aurErr.Error())
} else {
report.AURUpdates = parseArrowUpdates(aurOut, "aur")
}
}
}
sort.Slice(report.Updates, func(i, j int) bool { return report.Updates[i].Name < report.Updates[j].Name })
sort.Slice(report.AURUpdates, func(i, j int) bool { return report.AURUpdates[i].Name < report.AURUpdates[j].Name })
return report, nil
}
func (s *Service) RefreshPackageMetadata(ctx context.Context) (PolicyResult, error) {
s.mu.Lock()
defer s.mu.Unlock()
if err := s.requirePackages(ctx); err != nil {
return PolicyResult{}, err
}
pm := s.packageManager()
x, cancel := context.WithTimeout(ctx, 10*time.Minute)
defer cancel()
var out string
var err error
switch pm {
case "apt":
out, err = s.hostCommand(x, nil, "apt-get", "update")
case "dnf":
out, err = s.hostCommand(x, nil, "dnf", "makecache", "--refresh")
case "yum":
out, err = s.hostCommand(x, nil, "yum", "makecache", "-y")
case "zypper":
out, err = s.hostCommand(x, nil, "zypper", "--non-interactive", "refresh")
case "apk":
out, err = s.hostCommand(x, nil, "apk", "update")
case "pacman":
if !s.hostBinaryExists("/usr/bin/checkupdates", "/bin/checkupdates") {
return PolicyResult{}, errors.New("pacman-contrib/checkupdates ist erforderlich, damit Dockwatch Paketlisten ohne riskantes isoliertes pacman -Sy aktualisieren kann")
}
var code int
out, code, err = s.hostCommandExit(x, nil, "checkupdates", "--nocolor")
if err == nil && code != 0 && code != 2 {
err = fmt.Errorf("checkupdates exited with status %d: %s", code, strings.TrimSpace(out))
}
if err == nil {
return PolicyResult{OK: true, Message: "Arch-Paketlisten wurden sicher in der temporären checkupdates-Datenbank aktualisiert.", Output: limit(out, 12000)}, nil
}
default:
return PolicyResult{}, errors.New("unsupported package manager")
}
if err != nil {
return PolicyResult{}, err
}
return PolicyResult{OK: true, Message: "Paketmetadaten wurden aktualisiert.", Output: limit(out, 12000)}, nil
}
func (s *Service) UpgradePackages(ctx context.Context, in PackageUpgradeInput) (PolicyResult, error) {
s.mu.Lock()
defer s.mu.Unlock()
if err := s.requirePackages(ctx); err != nil {
return PolicyResult{}, err
}
scope := strings.ToLower(strings.TrimSpace(in.Scope))
if scope == "" {
scope = "system"
}
if scope != "system" && scope != "aur" && scope != "all" {
return PolicyResult{}, errors.New("scope must be system, aur, or all")
}
pm := s.packageManager()
if scope == "aur" && pm != "pacman" {
return PolicyResult{}, errors.New("AUR updates are only available on pacman-based hosts")
}
x, cancel := context.WithTimeout(ctx, 45*time.Minute)
defer cancel()
var out string
var err error
switch pm {
case "apt":
if scope == "aur" {
return PolicyResult{}, errors.New("AUR is not available on apt hosts")
}
if v, e := s.hostCommand(x, nil, "apt-get", "update"); e != nil {
return PolicyResult{}, e
} else {
out = v
}
v, e := s.hostCommand(x, nil, "env", "DEBIAN_FRONTEND=noninteractive", "apt-get", "dist-upgrade", "-y", "--no-remove")
if e != nil {
return PolicyResult{}, e
}
out += "\n" + v
case "dnf":
if scope == "aur" {
return PolicyResult{}, errors.New("AUR is not available on dnf hosts")
}
out, err = s.hostCommand(x, nil, "dnf", "upgrade", "--refresh", "-y")
case "yum":
if scope == "aur" {
return PolicyResult{}, errors.New("AUR is not available on yum hosts")
}
out, err = s.hostCommand(x, nil, "yum", "update", "-y")
case "zypper":
if scope == "aur" {
return PolicyResult{}, errors.New("AUR is not available on zypper hosts")
}
out, err = s.hostCommand(x, nil, "zypper", "--non-interactive", "update")
case "apk":
if scope == "aur" {
return PolicyResult{}, errors.New("AUR is not available on apk hosts")
}
out, err = s.hostCommand(x, nil, "apk", "upgrade")
case "pacman":
helper := s.aurHelper()
if scope == "system" || (scope == "all" && helper == "") {
out, err = s.hostCommand(x, nil, "pacman", "-Syu", "--noconfirm")
} else {
if helper == "" {
return PolicyResult{}, errors.New("no supported AUR helper detected (paru or yay)")
}
if _, e := s.aurUser(); e != nil {
return PolicyResult{}, e
}
op := "-Sua"
if scope == "all" {
op = "-Syu"
}
helperArgs := []string{op, "--noconfirm", "--sudoflags=-n"}
if helper == "paru" {
helperArgs = append(helperArgs, "--skipreview", "--noupgrademenu")
} else {
helperArgs = append(helperArgs, "--cleanmenu=false", "--diffmenu=false", "--editmenu=false", "--answerupgrade=None")
}
out, err = s.hostCommand(x, nil, s.aurCommand(helper, helperArgs...)...)
}
default:
return PolicyResult{}, errors.New("unsupported package manager")
}
if err != nil {
return PolicyResult{}, err
}
return PolicyResult{OK: true, Message: "System-Updates wurden installiert.", Output: limit(out, 20000)}, nil
}
func (s *Service) hostCommandExit(ctx context.Context, stdin []byte, args ...string) (string, int, error) {
if len(args) == 0 {
return "", -1, errors.New("host command is empty")
}
base := []string{"-t", fmt.Sprintf("%d", s.cfg.HostPID), "-m", "-u", "-i", "-n", "-p", "-r", "--"}
base = append(base, args...)
cmd := exec.CommandContext(ctx, "nsenter", base...)
if stdin != nil {
cmd.Stdin = strings.NewReader(string(stdin))
}
b, err := cmd.CombinedOutput()
out := strings.TrimSpace(string(b))
if err == nil {
return out, 0, nil
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return out, exitErr.ExitCode(), nil
}
return out, -1, err
}
func (s *Service) hostBinaryExists(paths ...string) bool {
for _, p := range paths {
if fileExists(s.hostPath(p)) {
return true
}
}
return false
}
func (s *Service) aurHelper() string {
if s.packageManager() != "pacman" {
return ""
}
if s.hostBinaryExists("/usr/bin/paru", "/usr/local/bin/paru") {
return "paru"
}
if s.hostBinaryExists("/usr/bin/yay", "/usr/local/bin/yay") {
return "yay"
}
return ""
}
type aurUserInfo struct {
Name string
Home string
}
func (s *Service) aurUser() (aurUserInfo, error) {
name := strings.TrimSpace(s.cfg.AURUser)
if name == "" {
return aurUserInfo{}, errors.New("HOST_AUR_USER is not configured")
}
if !safeNameRE.MatchString(name) || name == "root" {
return aurUserInfo{}, errors.New("HOST_AUR_USER must name a non-root local user")
}
b, err := os.ReadFile(s.hostPath("/etc/passwd"))
if err != nil {
return aurUserInfo{}, fmt.Errorf("read host passwd: %w", err)
}
for _, line := range strings.Split(string(b), "\n") {
parts := strings.Split(line, ":")
if len(parts) >= 6 && parts[0] == name {
if parts[2] == "0" {
return aurUserInfo{}, errors.New("HOST_AUR_USER must not be root")
}
return aurUserInfo{Name: name, Home: parts[5]}, nil
}
}
return aurUserInfo{}, fmt.Errorf("HOST_AUR_USER %q does not exist on the host", name)
}
func (s *Service) aurCommand(helper string, args ...string) []string {
u, err := s.aurUser()
if err != nil {
return []string{"false"}
}
cmd := []string{"runuser", "-u", u.Name, "--", "env", "HOME=" + u.Home, "USER=" + u.Name, "LOGNAME=" + u.Name, "TERM=dumb", "NO_COLOR=1", helper}
return append(cmd, args...)
}
func parseAPTUpdates(out string) []PackageUpdate {
rows := []PackageUpdate{}
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "Inst ") {
continue
}
fields := strings.Fields(line)
if len(fields) < 4 {
continue
}
name := fields[1]
oldStart := strings.Index(line, "[")
oldEnd := strings.Index(line, "]")
newStart := strings.Index(line, "(")
newEnd := strings.Index(line, ")")
if oldStart < 0 || oldEnd <= oldStart || newStart < 0 || newEnd <= newStart || oldStart > newStart {
continue
}
oldV := strings.TrimSpace(line[oldStart+1 : oldEnd])
inside := strings.Fields(line[newStart+1 : newEnd])
if len(inside) == 0 {
continue
}
newV := inside[0]
repo := ""
if len(inside) > 1 {
repo = strings.Join(inside[1:], " ")
}
rows = append(rows, PackageUpdate{Name: name, CurrentVersion: oldV, NewVersion: newV, Repository: repo, Source: "repo"})
}
return rows
}
func parseRPMUpdates(out string) []PackageUpdate {
rows := []PackageUpdate{}
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "Last metadata") || strings.HasPrefix(line, "Obsoleting") || strings.HasPrefix(line, "Security:") {
continue
}
f := strings.Fields(line)
if len(f) < 2 || strings.Contains(f[0], ":") || !strings.Contains(f[0], ".") {
continue
}
name, arch := f[0], ""
if i := strings.LastIndex(name, "."); i > 0 {
arch = name[i+1:]
name = name[:i]
}
repo := ""
if len(f) > 2 {
repo = f[2]
}
rows = append(rows, PackageUpdate{Name: name, Arch: arch, NewVersion: f[1], Repository: repo, Source: "repo"})
}
return rows
}
func parseArrowUpdates(out, source string) []PackageUpdate {
rows := []PackageUpdate{}
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(ansiEscapeRE.ReplaceAllString(line, ""))
m := packageArrowRE.FindStringSubmatch(line)
if len(m) != 4 {
continue
}
rows = append(rows, PackageUpdate{Name: m[1], CurrentVersion: m[2], NewVersion: m[3], Source: source})
}
return rows
}
func parseZypperUpdates(out string) []PackageUpdate {
rows := []PackageUpdate{}
for _, line := range strings.Split(out, "\n") {
if !strings.Contains(line, "|") || strings.Contains(line, "---") {
continue
}
parts := strings.Split(line, "|")
for i := range parts {
parts[i] = strings.TrimSpace(parts[i])
}
if len(parts) < 5 {
continue
}
offset := len(parts) - 5
if offset < 0 || offset+4 >= len(parts) || parts[offset] == "Repository" || parts[offset+1] == "Name" || parts[offset+1] == "" {
continue
}
rows = append(rows, PackageUpdate{Name: parts[offset+1], CurrentVersion: parts[offset+2], NewVersion: parts[offset+3], Repository: parts[offset], Arch: parts[offset+4], Source: "repo"})
}
return rows
}
func parseAPKUpdates(out string) []PackageUpdate {
rows := []PackageUpdate{}
for _, line := range strings.Split(out, "\n") {
m := apkUpdateRE.FindStringSubmatch(strings.TrimSpace(line))
if len(m) != 4 {
continue
}
rows = append(rows, PackageUpdate{Name: m[1], CurrentVersion: m[2], NewVersion: m[3], Source: "repo"})
}
return rows
}
+52
View File
@@ -0,0 +1,52 @@
package hostsecurity
import "testing"
func TestParseAPTUpdates(t *testing.T) {
out := `NOTE: This is only a simulation!
Inst bash [5.2.15-2+b2] (5.2.15-2+b8 Debian:12.12/oldstable [amd64])
Inst curl [7.88.1-10+deb12u12] (7.88.1-10+deb12u14 Debian-Security:12/oldstable-security [amd64])
Inst new-dependency (1.0 repo [amd64])`
got := parseAPTUpdates(out)
if len(got) != 2 {
t.Fatalf("expected 2 upgrades, got %#v", got)
}
if got[0].Name != "bash" || got[0].CurrentVersion != "5.2.15-2+b2" || got[0].NewVersion != "5.2.15-2+b8" {
t.Fatalf("unexpected first apt update: %#v", got[0])
}
}
func TestParseRPMUpdates(t *testing.T) {
out := `kernel.x86_64 6.12.1-1 updates
openssl-libs.x86_64 3.2.2-5 baseos`
got := parseRPMUpdates(out)
if len(got) != 2 || got[0].Name != "kernel" || got[0].Arch != "x86_64" || got[0].Repository != "updates" {
t.Fatalf("unexpected rpm updates: %#v", got)
}
}
func TestParseArrowUpdates(t *testing.T) {
got := parseArrowUpdates("linux 6.10.1 -> 6.10.2\nparu 2.0.3 -> 2.0.4", "repo")
if len(got) != 2 || got[1].Name != "paru" || got[1].NewVersion != "2.0.4" {
t.Fatalf("unexpected arrow updates: %#v", got)
}
}
func TestParseZypperUpdates(t *testing.T) {
out := `S | Repository | Name | Current Version | Available Version | Arch
--+------------+------+-----------------+-------------------+-------
v | repo-update | bash | 5.2-1 | 5.2-2 | x86_64`
got := parseZypperUpdates(out)
if len(got) != 1 || got[0].Name != "bash" || got[0].CurrentVersion != "5.2-1" || got[0].NewVersion != "5.2-2" {
t.Fatalf("unexpected zypper update: %#v", got)
}
}
func TestParseAPKUpdates(t *testing.T) {
got := parseAPKUpdates("busybox-1.36.1-r2 < 1.36.1-r4")
if len(got) != 1 || got[0].Name != "busybox" || got[0].CurrentVersion != "1.36.1-r2" {
// APK package/version separation is inherently ambiguous without apk policy output;
// this test locks the conservative parser behavior used by the UI.
t.Fatalf("unexpected apk update: %#v", got)
}
}
+1
View File
@@ -34,6 +34,7 @@ type Config struct {
HostRoot string
DataDir string
HostPID int
AURUser string
}
type Service struct {
+83
View File
@@ -144,6 +144,9 @@ func New(c config.Config, a *auth.Service, ss *stacks.Service, n *nodes.Manager,
api.Handle("PUT /api/security/auditd", auth.RequireRole("admin", http.HandlerFunc(s.securityApplyAuditd)))
api.Handle("POST /api/security/components/{component}/install", auth.RequireRole("admin", http.HandlerFunc(s.securityInstall)))
api.Handle("POST /api/security/components/{component}/actions/{action}", auth.RequireRole("admin", http.HandlerFunc(s.securityComponentAction)))
api.Handle("GET /api/packages/updates", auth.RequireRole("admin", http.HandlerFunc(s.packageUpdates)))
api.Handle("POST /api/packages/refresh", auth.RequireRole("admin", http.HandlerFunc(s.packageRefresh)))
api.Handle("POST /api/packages/upgrade", auth.RequireRole("admin", http.HandlerFunc(s.packageUpgrade)))
mux.Handle("/api/", a.Middleware(mutationOriginGuard(s.auditMiddleware(api))))
assets, _ := fs.Sub(web.FS, ".")
f := http.FileServer(http.FS(assets))
@@ -181,6 +184,9 @@ func (s *Server) agent(m *http.ServeMux) {
a.HandleFunc("PUT /agent/v1/security/auditd", s.localSecurityApplyAuditd)
a.HandleFunc("POST /agent/v1/security/components/{component}/install", s.localSecurityInstall)
a.HandleFunc("POST /agent/v1/security/components/{component}/actions/{action}", s.localSecurityComponentAction)
a.HandleFunc("GET /agent/v1/packages/updates", s.localPackageUpdates)
a.HandleFunc("POST /agent/v1/packages/refresh", s.localPackageRefresh)
a.HandleFunc("POST /agent/v1/packages/upgrade", s.localPackageUpgrade)
a.HandleFunc("GET /agent/v1/stacks", s.localList)
a.HandleFunc("GET /agent/v1/stacks/{name}", s.localGet)
a.HandleFunc("PUT /agent/v1/stacks/{name}", s.localSave)
@@ -1443,6 +1449,83 @@ func (s *Server) proxyTerminal(w http.ResponseWriter, r *http.Request, id int64)
}
}
func (s *Server) packageUpdates(w http.ResponseWriter, r *http.Request) {
if id := nodeID(r); id > 0 {
s.relayWithTimeout(w, r, id, http.MethodGet, "/agent/v1/packages/updates", nil, 2*time.Minute)
return
}
s.localPackageUpdates(w, r)
}
func (s *Server) localPackageUpdates(w http.ResponseWriter, r *http.Request) {
if s.security == nil {
http.Error(w, "host package service unavailable", http.StatusServiceUnavailable)
return
}
v, e := s.security.PackageUpdates(r.Context())
if e != nil {
http.Error(w, e.Error(), http.StatusBadRequest)
return
}
jsonOut(w, http.StatusOK, v)
}
func (s *Server) packageRefresh(w http.ResponseWriter, r *http.Request) {
if id := nodeID(r); id > 0 {
s.relayWithTimeout(w, r, id, http.MethodPost, "/agent/v1/packages/refresh", map[string]any{}, 12*time.Minute)
return
}
s.localPackageRefresh(w, r)
}
func (s *Server) localPackageRefresh(w http.ResponseWriter, r *http.Request) {
if s.security == nil {
http.Error(w, "host package service unavailable", http.StatusServiceUnavailable)
return
}
v, e := s.security.RefreshPackageMetadata(r.Context())
if e != nil {
http.Error(w, e.Error(), http.StatusBadRequest)
return
}
jsonOut(w, http.StatusOK, v)
}
func (s *Server) packageUpgrade(w http.ResponseWriter, r *http.Request) {
var in hostsecurity.PackageUpgradeInput
if e := read(r, &in); e != nil {
http.Error(w, e.Error(), http.StatusBadRequest)
return
}
if id := nodeID(r); id > 0 {
s.relayWithTimeout(w, r, id, http.MethodPost, "/agent/v1/packages/upgrade", in, 50*time.Minute)
return
}
s.packageUpgradeLocal(w, r, in)
}
func (s *Server) localPackageUpgrade(w http.ResponseWriter, r *http.Request) {
var in hostsecurity.PackageUpgradeInput
if e := read(r, &in); e != nil {
http.Error(w, e.Error(), http.StatusBadRequest)
return
}
s.packageUpgradeLocal(w, r, in)
}
func (s *Server) packageUpgradeLocal(w http.ResponseWriter, r *http.Request, in hostsecurity.PackageUpgradeInput) {
if s.security == nil {
http.Error(w, "host package service unavailable", http.StatusServiceUnavailable)
return
}
v, e := s.security.UpgradePackages(r.Context(), in)
if e != nil {
http.Error(w, e.Error(), http.StatusBadRequest)
return
}
jsonOut(w, http.StatusOK, v)
}
func (s *Server) securityStatus(w http.ResponseWriter, r *http.Request) {
if id := nodeID(r); id > 0 {
s.relay(w, r, id, http.MethodGet, "/agent/v1/security/status", nil)
+30 -2
View File
@@ -35,12 +35,12 @@ function fmtAgo(ts){if(!ts)return'nie';const s=Math.max(0,Math.floor(Date.now()/
function setCrumb(t){$('#crumb').textContent=t}
function nodeName(){return state.node?(state.nodes.find(n=>n.id===state.node)?.name||'Remote-Host'):'Lokaler Docker-Host'}
async function init(){applyPreferences();state.me=await api('/api/me');const [sysR,nodesR]=await Promise.allSettled([api('/api/system'),api('/api/nodes')]);state.system=sysR.status==='fulfilled'?sysR.value:null;state.nodes=nodesR.status==='fulfilled'&&Array.isArray(nodesR.value)?nodesR.value:[];renderUser();renderNodePicker();wireShell();await refreshData(true);navigate('dashboard')}
function renderUser(){const name=state.me.name||state.me.email||'Benutzer';$('#userName').textContent=name;$('#userRole').textContent=roleLabel(state.me.role);$('#avatar').textContent=name[0]?.toUpperCase()||'U';const b=state.system?.build;if($('#buildVersion'))$('#buildVersion').textContent=b?`v${b.version} · ${String(b.commit||'dev').slice(0,8)}`:'Dockwatch';const adminOnly=$$('#nav button[data-view="activity"],#nav button[data-view="notifications"],#nav button[data-view="security"]');adminOnly.forEach(e=>e.hidden=state.me.role!=='admin')}
function renderUser(){const name=state.me.name||state.me.email||'Benutzer';$('#userName').textContent=name;$('#userRole').textContent=roleLabel(state.me.role);$('#avatar').textContent=name[0]?.toUpperCase()||'U';const b=state.system?.build;if($('#buildVersion'))$('#buildVersion').textContent=b?`v${b.version} · ${String(b.commit||'dev').slice(0,8)}`:'Dockwatch';const adminOnly=$$('#nav button[data-view="activity"],#nav button[data-view="notifications"],#nav button[data-view="security"],#nav button[data-view="packages"]');adminOnly.forEach(e=>e.hidden=state.me.role!=='admin')}
function renderNodePicker(){const p=$('#globalNode');p.innerHTML=`<option value="0">Lokaler Docker-Host</option>${state.nodes.map(n=>`<option value="${n.id}" ${n.enabled?'':'disabled'}>${esc(n.name)}${n.enabled?'':' · deaktiviert'}</option>`).join('')}`;if(state.node&&state.nodes.some(n=>n.id===state.node&&!n.enabled))state.node=0;p.value=String(state.node);p.onchange=async()=>{if(state.dirty&&!confirm('Ungespeicherte Stack-Änderungen verwerfen?')){p.value=String(state.node);return}state.node=Number(p.value);state.stack=null;setDirty(false);await refreshData(true);render()}}
function wireShell(){$$('#nav button').forEach(b=>b.onclick=()=>navigate(b.dataset.view));$('#logout').onclick=async()=>{await api('/auth/logout',{method:'POST'});location='/auth/login'};$('#refreshNow').onclick=()=>refreshData(true).then(render).catch(e=>toast(e.message));$('#themeToggle').onclick=toggleTheme;$('#sidebarToggle').onclick=()=>{if(innerWidth<=860){document.body.classList.toggle('sidebar-mobile-open');return}const c=!document.body.classList.contains('sidebar-collapsed');localStorage.setItem('dockwatch:sidebar',c?'collapsed':'expanded');applyPreferences()};$('#mobileMenu')?.addEventListener('click',()=>document.body.classList.toggle('sidebar-mobile-open'));document.addEventListener('keydown',e=>{if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='s'&&state.view==='stacks'&&$('#saveStack')){e.preventDefault();saveStack()}if(e.key==='Escape'&&$('#modalRoot')?.innerHTML)closeModal()});window.addEventListener('online',()=>setApiState(true));window.addEventListener('offline',()=>setApiState(false,'Browser ist offline'));setInterval(()=>{if(!document.hidden)refreshData(false).catch(()=>{})},20000)}
function navigate(v){if(state.dirty&&state.view==='stacks'&&v!=='stacks'&&!confirm('Ungespeicherte Stack-Änderungen verlassen?'))return;if(v!==state.view)teardownInteractive();state.view=v;document.body.classList.remove('sidebar-mobile-open');$$('#nav button').forEach(b=>b.classList.toggle('active',b.dataset.view===v));render()}
async function refreshData(force=false){if(state.refreshing)return;state.refreshing=true;try{const [stR,moR,svR]=await Promise.allSettled([api('/api/stacks'+qnode()),api('/api/monitors'),api('/api/services')]);const errs=[];if(stR.status==='fulfilled')state.stacks=Array.isArray(stR.value)?stR.value:[];else errs.push('Stacks: '+stR.reason.message);if(moR.status==='fulfilled')state.monitors=Array.isArray(moR.value)?moR.value:[];else errs.push('Monitore: '+moR.reason.message);if(svR.status==='fulfilled')state.services=Array.isArray(svR.value)?svR.value:[];else errs.push('Dienste: '+svR.reason.message);$('#navStackCount').textContent=state.stacks.length;$('#navMonitorCount').textContent=state.monitors.length;if(force||!state.dirty){if(state.stack?.name){const n=state.stacks.find(x=>x.name===state.stack.name);if(!n&&stR.status==='fulfilled')state.stack=null}if(state.monitor&&moR.status==='fulfilled')state.monitor=state.monitors.find(m=>m.id===state.monitor.id)||state.monitor}if(errs.length&&force)toast(errs.join(' · '))}finally{state.refreshing=false}}
function render(){({dashboard:renderDashboard,stacks:renderStacks,monitors:renderMonitors,services:renderServices,statuspages:renderStatusPages,maintenance:renderMaintenance,nodes:renderNodes,containers:renderDockerResource,images:renderDockerResource,volumes:renderDockerResource,networks:renderDockerResource,git:renderGit,notifications:renderNotifications,security:renderSecurity,activity:renderActivity}[state.view]||renderDashboard)()}
function render(){({dashboard:renderDashboard,stacks:renderStacks,monitors:renderMonitors,services:renderServices,statuspages:renderStatusPages,maintenance:renderMaintenance,nodes:renderNodes,containers:renderDockerResource,images:renderDockerResource,volumes:renderDockerResource,networks:renderDockerResource,git:renderGit,notifications:renderNotifications,security:renderSecurity,packages:renderPackages,activity:renderActivity}[state.view]||renderDashboard)()}
function pageHead(title,sub,actions=''){return `<div class="pagehead"><div><h1>${esc(title)}</h1><p>${esc(sub)}</p></div><div class="toolbar">${actions}</div></div>`}
function renderDashboard(){setCrumb('Übersicht');const running=state.stacks.filter(s=>s.status==='running').length,down=state.monitors.filter(m=>m.status==='down').length,up=state.monitors.filter(m=>m.status==='up').length,svcDown=state.services.filter(s=>s.status==='down').length,svcUp=state.services.filter(s=>s.status==='up').length;$('#content').innerHTML=`${pageHead('Übersicht',nodeName()+' · Docker- und Uptime-Übersicht','<span class="keyboardHint"><kbd>Ctrl</kbd>+<kbd>S</kbd> speichert Stacks</span><button class="btn" id="dashRefresh">↻ Aktualisieren</button>')}<div class="stats"><div class="stat"><small>Compose-Stacks</small><strong>${state.stacks.length}</strong><div class="trend"><span class="green">${running} aktiv</span></div></div><div class="stat"><small>Monitore verfügbar</small><strong>${up}</strong><div class="trend">${state.monitors.length} konfiguriert</div></div><div class="stat"><small>Monitor-Störungen</small><strong class="${down?'red':''}">${down}</strong><div class="trend">aktuelle Prüfungsfehler</div></div><div class="stat"><small>Dienste</small><strong class="${svcDown?'red':''}">${svcUp}/${state.services.length}</strong><div class="trend">${svcDown} gestört</div></div></div><div class="twocol"><div class="panel"><div class="panelhead"><h2>Compose-Stacks</h2><button class="btn tiny" id="goStacks">Alle anzeigen</button></div>${stackTable(state.stacks.slice(0,8))}</div><div class="panel"><div class="panelhead"><h2>Uptime-Monitore</h2><button class="btn tiny" id="goMons">Alle anzeigen</button></div>${monitorTable(state.monitors.slice(0,8))}</div></div>`;$('#dashRefresh').onclick=()=>refreshData(true).then(render);$('#goStacks').onclick=()=>navigate('stacks');$('#goMons').onclick=()=>navigate('monitors');$$('[data-openstack]').forEach(x=>x.onclick=()=>openStack(x.dataset.openstack));$$('[data-openmon]').forEach(x=>x.onclick=()=>openMonitor(Number(x.dataset.openmon)))}
function stackTable(items){if(!items.length)return'<div class="empty">Keine Compose-Stacks gefunden.</div>';return `<table class="table"><thead><tr><th>Name</th><th>Status</th><th>Dienste</th><th>Images</th></tr></thead><tbody>${items.map(s=>`<tr class="${roleOK()?'clickrow':''}" data-openstack="${esc(s.name)}"><td><div class="namecell"><span class="cube">▱</span><b>${esc(s.name)}</b></div></td><td>${badge(s.status)}</td><td>${(s.services||[]).length}</td><td class="muted">${esc((s.services||[]).slice(0,2).map(v=>v.image).filter(Boolean).join(', ')||'—')}</td></tr>`).join('')}</tbody></table>`}
@@ -228,6 +228,34 @@ function notificationModal(x=null){const type=x?.type||'webhook';modal(`<div cla
async function renderPackages(){
setCrumb(`System / System-Updates / ${nodeName()}`);
if(state.me.role!=='admin'){ $('#content').innerHTML=`${pageHead('System-Updates','Administratorrechte erforderlich.')}<div class="empty">Paketverwaltung ist auf Administratoren beschränkt.</div>`; return }
$('#content').innerHTML=`${pageHead('System-Updates',`Verfügbare Linux-Paketupdates prüfen und installieren · ${nodeName()}`,'<button class="btn" id="pkgReload">↻ Prüfen</button>')}<div class="panel"><div class="empty">Paketupdates werden geprüft…</div></div>`;
$('#pkgReload').onclick=renderPackages;
try{
const d=await api('/api/packages/updates'+qnode()),repo=asArray(d.updates),aur=asArray(d.aur_updates),can=!!d.can_manage,pm=d.package_manager||'unbekannt',aurReady=pm==='pacman'&&d.aur_helper&&d.aur_user;
const total=repo.length+aur.length;
const actionDisabled=can?'':'disabled title="Paketinstallation ist für diese Umgebung nicht freigegeben"';
$('#content').innerHTML=`${pageHead('System-Updates',`Verfügbare Linux-Paketupdates prüfen und installieren · ${nodeName()}`,'<button class="btn" id="pkgReload">↻ Prüfen</button>')}
<div class="packageHero"><div><small>PAKETSTATUS</small><div class="packageCount">${total}<span> Updates</span></div><p>${esc(d.os?.pretty_name||d.os?.name||'Host-Betriebssystem unbekannt')} · ${esc(pm)}${d.aur_helper?` · AUR: ${esc(d.aur_helper)}`:''}</p></div><div class="packageActions"><button class="btn" id="pkgRefresh" ${actionDisabled}>Paketlisten aktualisieren</button><button class="btn primary" id="pkgUpgradeSystem" ${repo.length&&can?'':'disabled'}>${pm==='pacman'?'Pacman-System aktualisieren':'Systemupdates installieren'}</button>${d.aur_helper?`<button class="btn" id="pkgUpgradeAur" ${aur.length&&aurReady&&can?'':'disabled'}>AUR aktualisieren</button><button class="btn primary" id="pkgUpgradeAll" ${(repo.length||aur.length)&&aurReady&&can?'':'disabled'}>Alle Updates</button>`:''}</div></div>
${!can?`<div class="notice warnNotice"><b>Nur Prüfung:</b> Für Installationen müssen <code>HOST_SECURITY_ENABLED</code>, <code>ALLOW_HOST_SECURITY_CHANGES</code> und <code>ALLOW_HOST_PACKAGE_MANAGEMENT</code> aktiviert sein.</div>`:''}
${pm==='pacman'&&d.aur_helper&&!d.aur_user?`<div class="notice warnNotice"><b>AUR nicht konfiguriert:</b> Setze <code>HOST_AUR_USER</code> auf einen lokalen Nicht-root-Benutzer, der ${esc(d.aur_helper)} verwendet und Paket-Eskalation non-interaktiv ausführen darf.</div>`:''}
${asArray(d.warnings).map(w=>`<div class="notice warnNotice">${esc(uiMessage(w))}</div>`).join('')}
<div class="panel" style="margin-top:12px"><div class="panelhead"><h2>System-Repositories</h2><span class="tag">${repo.length} verfügbar</span></div>${packageUpdateTable(repo,'Keine Updates aus den System-Repositories verfügbar.')}</div>
${pm==='pacman'||d.aur_helper?`<div class="panel" style="margin-top:12px"><div class="panelhead"><h2>AUR</h2><span class="tag">${aur.length} verfügbar</span></div><div class="notice warnNotice" style="margin:10px"><b>AUR-Vertrauensgrenze:</b> AUR-Pakete stammen aus benutzergepflegten PKGBUILDs. Für den automatisierten Dockwatch-Workflow werden Review-/Auswahlmenüs des Helpers deaktiviert; installiere AUR-Updates nur auf Hosts, deren AUR-Pakete du bewusst vertraust.</div>${d.aur_helper?packageUpdateTable(aur,'Keine AUR-Updates verfügbar.'):'<div class="empty">Kein unterstützter AUR-Helper erkannt. Unterstützt werden <code>paru</code> und <code>yay</code>.</div>'}</div>`:''}
<div class="panel" style="margin-top:12px"><div class="panelhead"><h2>Sicherheitsregeln für Paketupdates</h2></div><div class="securitySafety"><div><b>Keine freien Shell-Kommandos</b><p>Das Interface kann nur vordefinierte Update-Aktionen auslösen.</p></div><div><b>Arch ohne Partial Upgrade</b><p>Offizielle Arch-Pakete werden ausschließlich über vollständiges <code>pacman -Syu</code> aktualisiert.</p></div><div><b>AUR als Benutzer</b><p><code>paru</code>/<code>yay</code> laufen als konfigurierter Nicht-root-Benutzer; sudo wird non-interaktiv angefordert.</p></div><div><b>Master/Agent identisch</b><p>Remote-Aktionen werden an den ausgewählten Agent weitergereicht und dort gegen dessen Berechtigungen geprüft.</p></div></div></div>`;
$('#pkgReload').onclick=renderPackages;
$('#pkgRefresh')?.addEventListener('click',()=>packageRefresh($('#pkgRefresh')));
$('#pkgUpgradeSystem')?.addEventListener('click',()=>packageUpgrade('system',$('#pkgUpgradeSystem'),repo.length));
$('#pkgUpgradeAur')?.addEventListener('click',()=>packageUpgrade('aur',$('#pkgUpgradeAur'),aur.length));
$('#pkgUpgradeAll')?.addEventListener('click',()=>packageUpgrade('all',$('#pkgUpgradeAll'),total));
}catch(e){$('#content').innerHTML=`${pageHead('System-Updates',nodeName(),'<button class="btn" id="pkgReload">↻ Erneut prüfen</button>')}<div class="empty red">${esc(uiMessage(e.message))}</div>`;$('#pkgReload').onclick=renderPackages}
}
function packageUpdateTable(rows,emptyText){if(!rows.length)return `<div class="empty">${emptyText}</div>`;return `<div class="tablewrap"><table class="packageTable"><thead><tr><th>Paket</th><th>Installiert</th><th>Verfügbar</th><th>Quelle</th></tr></thead><tbody>${rows.map(x=>`<tr><td><b>${esc(x.name)}</b>${x.arch?`<small>${esc(x.arch)}</small>`:''}</td><td>${esc(x.current_version||'—')}</td><td><b>${esc(x.new_version||'—')}</b></td><td>${esc(x.repository||x.source||'—')}</td></tr>`).join('')}</tbody></table></div>`}
async function packageRefresh(btn){setBusy(btn,true,'Wird aktualisiert…');try{const out=await api(`/api/packages/refresh${qnode()}`,{method:'POST',body:'{}'});toast(out.message||'Paketlisten aktualisiert.');await renderPackages()}catch(e){toast(uiMessage(e.message));setBusy(btn,false)}}
async function packageUpgrade(scope,btn,count){const label=scope==='aur'?'AUR-Updates':scope==='all'?'alle System- und AUR-Updates':'Systemupdates',aurRisk=scope==='aur'||scope==='all'?' AUR-PKGBUILDs werden dabei ohne interaktives Review gebaut und installiert.':'';if(!confirm(`${count||''} ${label} auf ${nodeName()} jetzt installieren?${aurRisk} Paketdienste können dabei neu gestartet werden und ein Reboot kann anschließend erforderlich sein.`))return;setBusy(btn,true,'Updates werden installiert…');try{const out=await api(`/api/packages/upgrade${qnode()}`,{method:'POST',body:JSON.stringify({scope})});toast(out.message||'Updates installiert.');await renderPackages();if(out.output)showOutput(`System-Updates · ${nodeName()}`,out.output)}catch(e){toast(uiMessage(e.message));setBusy(btn,false)}}
async function renderSecurity(){
setCrumb(`System / Host-Sicherheit / ${nodeName()}`);
if(state.me.role!=='admin'){ $('#content').innerHTML=`${pageHead('Host-Sicherheit','Administratorrechte erforderlich.')}<div class="empty">Die Host-Sicherheitskonfiguration ist auf Administratoren beschränkt.</div>`; return }
+3 -2
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Dockwatch</title>
<link rel="stylesheet" href="/styles.css?v=9.5.3">
<link rel="stylesheet" href="/styles.css?v=9.6.0">
</head>
<body>
<div id="shell">
@@ -29,6 +29,7 @@
<div class="navlabel">System</div>
<button data-view="nodes"><span>◎</span>Umgebungen</button>
<button data-view="security"><span>⛨</span>Host-Sicherheit</button>
<button data-view="packages"><span>⇧</span>System-Updates</button>
<button data-view="activity"><span>≋</span>Aktivitäten</button>
</nav>
<div class="sidebarMeta"><span id="buildVersion">v…</span></div><div class="sidebarFooter"><div class="avatar" id="avatar">U</div><div class="usertext"><b id="userName">…</b><small id="userRole">…</small></div><button id="logout" title="Abmelden">↪</button></div>
@@ -39,5 +40,5 @@
</main>
</div>
<div id="modalRoot"></div><div id="toast"></div>
<script src="/app.js?v=9.5.3"></script>
<script src="/app.js?v=9.6.0"></script>
</body></html>
+8
View File
@@ -186,3 +186,11 @@ body.sidebar-collapsed #shell{grid-template-columns:64px 1fr}body.sidebar-collap
:root[data-theme="light"] .placeholder{background:#f8fafc;color:#94a3b8}
:root[data-theme="light"] .environment{background:#fff}
:root[data-theme="light"] .sidebarFooter{background:#fff}
/* v9.6.0 system package updates */
.packageHero{display:flex;justify-content:space-between;align-items:center;gap:20px;border:1px solid var(--line);border-radius:10px;padding:18px 20px;margin-bottom:12px;background:linear-gradient(135deg,var(--panel),var(--panel2))}.packageHero small{font-size:9px;letter-spacing:.12em;color:var(--muted)}.packageHero p{margin:5px 0 0;color:var(--muted);font-size:11px}.packageCount{font-size:38px;font-weight:750;line-height:1;margin-top:4px}.packageCount span{font-size:13px;color:var(--muted);font-weight:500}.packageActions{display:flex;gap:7px;flex-wrap:wrap;justify-content:flex-end}.packageTable{width:100%;border-collapse:collapse;min-width:720px}.packageTable th,.packageTable td{padding:9px 10px;text-align:left;border-bottom:1px solid var(--line);font-size:10px}.packageTable th{background:var(--panel2);color:var(--muted);font-size:9px;text-transform:uppercase;letter-spacing:.05em}.packageTable tbody tr:last-child td{border-bottom:0}.packageTable td:first-child b{display:block;font-size:11px}.packageTable td:first-child small{display:block;color:var(--muted);margin-top:2px}.packageTable tbody tr:hover{background:var(--panel2)}
@media(max-width:860px){.packageHero{align-items:flex-start;flex-direction:column}.packageActions{justify-content:flex-start;width:100%}}
:root[data-theme="light"] .packageHero{background:linear-gradient(135deg,#fff,#f8fafc)}
:root[data-theme="light"] .packageTable th{background:#f8fafc;color:#64748b}
:root[data-theme="light"] .packageTable td{border-bottom-color:#e5eaf1}
:root[data-theme="light"] .packageTable tbody tr:hover{background:#f8fafc}