diff --git a/.env.example b/.env.example index c0e8adf..3d49de2 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/Dockerfile b/Dockerfile index b891a3e..0bcdb27 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 ./ diff --git a/Makefile b/Makefile index 914f257..0b66159 100644 --- a/Makefile +++ b/Makefile @@ -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) diff --git a/README.md b/README.md index ab29170..14662af 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/cmd/dockwatch/main.go b/cmd/dockwatch/main.go index 43e6b4a..d28d2ff 100644 --- a/cmd/dockwatch/main.go +++ b/cmd/dockwatch/main.go @@ -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}) diff --git a/compose.yml b/compose.yml index 6e0e055..34bd862 100644 --- a/compose.yml +++ b/compose.yml @@ -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 diff --git a/examples/compose-agent.yml b/examples/compose-agent.yml index 938b796..cd3853d 100644 --- a/examples/compose-agent.yml +++ b/examples/compose-agent.yml @@ -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 diff --git a/examples/compose-host-security-audit.override.yml b/examples/compose-host-security-audit.override.yml index 13061c4..d6ac31b 100644 --- a/examples/compose-host-security-audit.override.yml +++ b/examples/compose-host-security-audit.override.yml @@ -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 diff --git a/examples/compose-host-security.override.yml b/examples/compose-host-security.override.yml index ac7ba28..906d269 100644 --- a/examples/compose-host-security.override.yml +++ b/examples/compose-host-security.override.yml @@ -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 diff --git a/internal/buildinfo/buildinfo.go b/internal/buildinfo/buildinfo.go index a0ab114..6b2c63d 100644 --- a/internal/buildinfo/buildinfo.go +++ b/internal/buildinfo/buildinfo.go @@ -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" ) diff --git a/internal/config/config.go b/internal/config/config.go index a1b48d0..576c7ac 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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, diff --git a/internal/hostsecurity/packages.go b/internal/hostsecurity/packages.go new file mode 100644 index 0000000..6261415 --- /dev/null +++ b/internal/hostsecurity/packages.go @@ -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 +} diff --git a/internal/hostsecurity/packages_test.go b/internal/hostsecurity/packages_test.go new file mode 100644 index 0000000..a6196d6 --- /dev/null +++ b/internal/hostsecurity/packages_test.go @@ -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) + } +} diff --git a/internal/hostsecurity/security.go b/internal/hostsecurity/security.go index 2ff3e47..5ae3c38 100644 --- a/internal/hostsecurity/security.go +++ b/internal/hostsecurity/security.go @@ -34,6 +34,7 @@ type Config struct { HostRoot string DataDir string HostPID int + AURUser string } type Service struct { diff --git a/internal/httpapi/httpapi.go b/internal/httpapi/httpapi.go index 5ff29a2..5a9749d 100644 --- a/internal/httpapi/httpapi.go +++ b/internal/httpapi/httpapi.go @@ -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) diff --git a/web/app.js b/web/app.js index f3603e6..626de8a 100644 --- a/web/app.js +++ b/web/app.js @@ -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=`${state.nodes.map(n=>``).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 `
${esc(sub)}
| Name | Status | Dienste | Images |
|---|---|---|---|
▱${esc(s.name)} | ${badge(s.status)} | ${(s.services||[]).length} | ${esc((s.services||[]).slice(0,2).map(v=>v.image).filter(Boolean).join(', ')||'—')} |
${esc(d.os?.pretty_name||d.os?.name||'Host-Betriebssystem unbekannt')} · ${esc(pm)}${d.aur_helper?` · AUR: ${esc(d.aur_helper)}`:''}
HOST_SECURITY_ENABLED, ALLOW_HOST_SECURITY_CHANGES und ALLOW_HOST_PACKAGE_MANAGEMENT aktiviert sein.HOST_AUR_USER auf einen lokalen Nicht-root-Benutzer, der ${esc(d.aur_helper)} verwendet und Paket-Eskalation non-interaktiv ausführen darf.paru und yay.Das Interface kann nur vordefinierte Update-Aktionen auslösen.
Offizielle Arch-Pakete werden ausschließlich über vollständiges pacman -Syu aktualisiert.
paru/yay laufen als konfigurierter Nicht-root-Benutzer; sudo wird non-interaktiv angefordert.
Remote-Aktionen werden an den ausgewählten Agent weitergereicht und dort gegen dessen Berechtigungen geprüft.
| Paket | Installiert | Verfügbar | Quelle |
|---|---|---|---|
| ${esc(x.name)}${x.arch?`${esc(x.arch)}`:''} | ${esc(x.current_version||'—')} | ${esc(x.new_version||'—')} | ${esc(x.repository||x.source||'—')} |