@@ -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"
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ type Config struct {
|
||||
HostRoot string
|
||||
DataDir string
|
||||
HostPID int
|
||||
AURUser string
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user