// Package updates is the package-manager view: what can be upgraded, when the index // was last refreshed, whether a reboot is pending, and the refresh/upgrade actions, // which run as background tasks so their output can be followed live. // // Supported: apt (Debian, Ubuntu), dnf (Fedora, RHEL family), zypper (openSUSE, SLES), // pacman (Arch) and apk (Alpine). All of them list, refresh, upgrade and install; pacman // upgrades everything or nothing, because Arch doesn't support partial upgrades. package updates import ( "bufio" "context" "errors" "os" "os/exec" "path/filepath" "regexp" "sort" "strings" "time" "hamlab/internal/priv" "hamlab/internal/tasks" ) type Package struct { Name string `json:"name"` Current string `json:"current"` Candidate string `json:"candidate"` Suite string `json:"suite,omitempty"` Security bool `json:"security"` Description string `json:"description,omitempty"` } type Status struct { Manager string `json:"manager"` // apt | dnf | zypper | pacman | apk | "" CanUpgrade bool `json:"can_upgrade"` Selective bool `json:"selective"` // individual packages can be upgraded Packages []Package `json:"packages"` SecurityCount int `json:"security_count"` LastRefresh int64 `json:"last_refresh,omitempty"` // unix ms LastUpgrade int64 `json:"last_upgrade,omitempty"` RebootNeeded bool `json:"reboot_needed"` RebootPkgs []string `json:"reboot_pkgs"` AutoUpgrades bool `json:"auto_upgrades"` RunningTask string `json:"running_task,omitempty"` Sudo bool `json:"sudo"` } type Manager struct { sudo bool tasks *tasks.Manager pm string } func New(sudo bool, tm *tasks.Manager) *Manager { m := &Manager{sudo: sudo, tasks: tm} for _, pm := range []string{"apt-get", "dnf", "zypper", "pacman", "apk"} { if _, err := exec.LookPath(pm); err == nil { m.pm = strings.TrimSuffix(pm, "-get") break } } return m } func (m *Manager) sudoArgs(args ...string) []string { if m.sudo { return priv.Wrap(args...) } return args } func out(ctx context.Context, args ...string) (string, error) { ctx, cancel := context.WithTimeout(ctx, 60*time.Second) defer cancel() cmd := exec.CommandContext(ctx, args[0], args[1:]...) cmd.Env = append(os.Environ(), "LC_ALL=C", "DEBIAN_FRONTEND=noninteractive") b, err := cmd.Output() if err != nil { var ee *exec.ExitError if errors.As(err, &ee) && len(ee.Stderr) > 0 { return string(b), errors.New(strings.TrimSpace(string(ee.Stderr))) } return string(b), err } return string(b), nil } // newestMtime: the newest mtime among the paths matching any of the globs. func newestMtime(globs ...string) int64 { var best int64 for _, g := range globs { matches, _ := filepath.Glob(g) for _, m := range matches { if t := mtimeMs(m); t > best { best = t } } } return best } // dnfLastUpgrade: newest transaction in `dnf history` whose action includes an upgrade, // else the newest transaction of any kind. func dnfLastUpgrade(ctx context.Context) int64 { text, err := out(ctx, "dnf", "history", "list") if err != nil { return 0 } re := regexp.MustCompile(`(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\s+(\S+)`) var newest, upgrade int64 for _, line := range strings.Split(text, "\n") { m := re.FindStringSubmatch(line) if m == nil { continue } t, err := time.ParseInLocation("2006-01-02 15:04:05", m[1], time.Local) if err != nil { continue } ms := t.UnixMilli() if ms > newest { newest = ms } if strings.Contains(m[2], "U") && ms > upgrade { upgrade = ms } } if upgrade > 0 { return upgrade } return newest } func mtimeMs(p string) int64 { st, err := os.Stat(p) if err != nil { return 0 } return st.ModTime().UnixMilli() } // ---- status ---- func (m *Manager) Status(ctx context.Context) Status { s := Status{Manager: m.pm, Packages: []Package{}, RebootPkgs: []string{}, Sudo: m.sudo} if t, ok := m.tasks.RunningOf("updates"); ok { s.RunningTask = t.ID } s.CanUpgrade = m.sudo && m.pm != "" s.Selective = m.pm != "pacman" switch m.pm { case "apt": s.Packages = aptUpgradable(ctx) s.LastRefresh = mtimeMs("/var/lib/apt/periodic/update-success-stamp") if s.LastRefresh == 0 { s.LastRefresh = mtimeMs("/var/lib/apt/lists/partial") } if s.LastRefresh == 0 { s.LastRefresh = mtimeMs("/var/lib/apt/lists") } s.LastUpgrade = aptLastUpgrade() s.AutoUpgrades = aptAutoUpgrades(ctx) case "dnf": s.Packages = dnfUpgradable(ctx) s.LastRefresh = newestMtime("/var/cache/libdnf5/*/repodata", "/var/cache/dnf/*/repodata", "/var/cache/libdnf5", "/var/cache/dnf") s.LastUpgrade = dnfLastUpgrade(ctx) case "zypper": s.Packages = zypperUpgradable(ctx) s.LastRefresh = newestMtime("/var/cache/zypp/raw/*", "/var/cache/zypp/raw") s.LastUpgrade = mtimeMs("/var/log/zypp/history") case "pacman": s.Packages = pacmanUpgradable(ctx) s.LastRefresh = newestMtime("/var/lib/pacman/sync/*.db", "/var/lib/pacman/sync") s.LastUpgrade = pacmanLastUpgrade() case "apk": s.Packages = apkUpgradable(ctx) s.LastRefresh = newestMtime("/var/cache/apk/APKINDEX.*", "/var/lib/apk/APKINDEX.*") s.LastUpgrade = mtimeMs("/lib/apk/db/installed") } for _, p := range s.Packages { if p.Security { s.SecurityCount++ } } if _, err := os.Stat("/run/reboot-required"); err == nil { s.RebootNeeded = true if b, err := os.ReadFile("/run/reboot-required.pkgs"); err == nil { for _, l := range strings.Split(strings.TrimSpace(string(b)), "\n") { if l = strings.TrimSpace(l); l != "" { s.RebootPkgs = append(s.RebootPkgs, l) } } } } else if m.pm != "apt" { s.RebootNeeded = kernelChanged() } return s } // kernelChanged: the running kernel isn't the newest installed one (dnf/pacman hosts). func kernelChanged() bool { b, err := os.ReadFile("/proc/sys/kernel/osrelease") if err != nil { return false } running := strings.TrimSpace(string(b)) ents, err := os.ReadDir("/lib/modules") if err != nil { return false } newest := "" for _, e := range ents { if e.Name() > newest { newest = e.Name() } } return newest != "" && newest != running } // ---- apt ---- var aptListRe = regexp.MustCompile(`^(\S+)/(\S+)\s+(\S+)\s+\S+\s+\[upgradable from: (\S+)\]`) func aptUpgradable(ctx context.Context) []Package { text, _ := out(ctx, "apt", "list", "--upgradable") var pkgs []Package for _, line := range strings.Split(text, "\n") { m := aptListRe.FindStringSubmatch(line) if m == nil { continue } pkgs = append(pkgs, Package{Name: m[1], Suite: m[2], Candidate: m[3], Current: m[4], Security: strings.Contains(m[2], "security")}) } if len(pkgs) == 0 { return []Package{} } // One apt-cache call for all the descriptions. args := append([]string{"apt-cache", "show", "--no-all-versions"}, names(pkgs)...) if text, err := out(ctx, args...); err == nil { desc := map[string]string{} cur := "" for _, line := range strings.Split(text, "\n") { if strings.HasPrefix(line, "Package: ") { cur = strings.TrimPrefix(line, "Package: ") } else if strings.HasPrefix(line, "Description-en: ") || strings.HasPrefix(line, "Description: ") { if _, ok := desc[cur]; !ok { _, v, _ := strings.Cut(line, ": ") desc[cur] = v } } } for i := range pkgs { pkgs[i].Description = desc[pkgs[i].Name] } } sort.Slice(pkgs, func(i, j int) bool { return pkgs[i].Name < pkgs[j].Name }) return pkgs } func names(pkgs []Package) []string { n := make([]string, len(pkgs)) for i, p := range pkgs { n[i] = p.Name } return n } // aptLastUpgrade: the Start-Date of the last history.log entry that upgraded something. func aptLastUpgrade() int64 { f, err := os.Open("/var/log/apt/history.log") if err != nil { return 0 } defer f.Close() var last, start string sc := bufio.NewScanner(f) sc.Buffer(make([]byte, 0, 64<<10), 8<<20) for sc.Scan() { l := sc.Text() switch { case strings.HasPrefix(l, "Start-Date: "): start = strings.TrimPrefix(l, "Start-Date: ") case strings.HasPrefix(l, "Upgrade: "): last = start } } if last == "" { return 0 } t, err := time.ParseInLocation("2006-01-02 15:04:05", last, time.Local) if err != nil { return 0 } return t.UnixMilli() } func aptAutoUpgrades(ctx context.Context) bool { text, _ := out(ctx, "apt-config", "dump", "APT::Periodic::Unattended-Upgrade") return strings.Contains(text, `"1"`) } // ---- dnf / zypper / pacman / apk ---- func dnfUpgradable(ctx context.Context) []Package { text, _ := out(ctx, "dnf", "-q", "check-update") // exit 100 when updates exist; output still parses var pkgs []Package for _, line := range strings.Split(text, "\n") { f := strings.Fields(line) if len(f) != 3 || !strings.Contains(f[0], ".") { continue } name := f[0][:strings.LastIndex(f[0], ".")] pkgs = append(pkgs, Package{Name: name, Candidate: f[1], Suite: f[2], Security: strings.Contains(f[2], "security")}) } if pkgs == nil { return []Package{} } // check-update only names the candidate; one rpm query fills in what's installed. args := append([]string{"rpm", "-q", "--qf", `%{NAME} %|EPOCH?{%{EPOCH}:}:{}|%{VERSION}-%{RELEASE}\n`}, names(pkgs)...) if text, _ := out(ctx, args...); text != "" { // exits non-zero when any name isn't installed (new dependencies) cur := map[string]string{} for _, line := range strings.Split(text, "\n") { if f := strings.Fields(line); len(f) == 2 { cur[f[0]] = f[1] // several installed (kernels): the last listed is the newest } } for i := range pkgs { pkgs[i].Current = cur[pkgs[i].Name] } } return pkgs } func pacmanUpgradable(ctx context.Context) []Package { text, _ := out(ctx, "pacman", "-Qu") var pkgs []Package for _, line := range strings.Split(text, "\n") { f := strings.Fields(line) // name old -> new if len(f) == 4 && f[2] == "->" { pkgs = append(pkgs, Package{Name: f[0], Current: f[1], Candidate: f[3]}) } } if pkgs == nil { return []Package{} } return pkgs } // zypperUpgradable parses the table `zypper list-updates` prints: // S | Repository | Name | Current Version | Available Version | Arch func zypperUpgradable(ctx context.Context) []Package { text, _ := out(ctx, "zypper", "--non-interactive", "--quiet", "list-updates") pkgs := []Package{} for _, line := range strings.Split(text, "\n") { f := strings.Split(line, "|") if len(f) < 5 || strings.TrimSpace(f[0]) != "v" { continue } repo := strings.TrimSpace(f[1]) pkgs = append(pkgs, Package{Name: strings.TrimSpace(f[2]), Current: strings.TrimSpace(f[3]), Candidate: strings.TrimSpace(f[4]), Suite: repo, Security: strings.Contains(strings.ToLower(repo), "security")}) } return pkgs } var apkVerRe = regexp.MustCompile(`^(.+)-([^-]+-r\d+)\s+<\s+(\S+)`) // apkUpgradable parses `apk version -l '<'`: "musl-1.2.4-r2 < 1.2.5-r0". func apkUpgradable(ctx context.Context) []Package { text, _ := out(ctx, "apk", "version", "-l", "<") pkgs := []Package{} for _, line := range strings.Split(text, "\n") { if m := apkVerRe.FindStringSubmatch(strings.TrimSpace(line)); m != nil { pkgs = append(pkgs, Package{Name: m[1], Current: m[2], Candidate: m[3]}) } } return pkgs } // pacmanLastUpgrade: the timestamp of the last "upgraded" line in pacman.log. func pacmanLastUpgrade() int64 { f, err := os.Open("/var/log/pacman.log") if err != nil { return 0 } defer f.Close() last := "" sc := bufio.NewScanner(f) sc.Buffer(make([]byte, 0, 64<<10), 1<<20) for sc.Scan() { if l := sc.Text(); strings.Contains(l, "[ALPM] upgraded ") && strings.HasPrefix(l, "[") { if end := strings.IndexByte(l, ']'); end > 1 { last = l[1:end] } } } for _, layout := range []string{"2006-01-02T15:04:05-0700", "2006-01-02 15:04"} { if t, err := time.ParseInLocation(layout, last, time.Local); err == nil { return t.UnixMilli() } } return 0 } // ---- actions ---- // Never starts with '-', so a name can't be read as an option by any of the managers. var pkgNameRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9+._-]{0,127}$`) func checkNames(pkgs []string) error { for _, p := range pkgs { if !pkgNameRe.MatchString(p) { return errors.New("bad package name: " + p) } } return nil } func (m *Manager) start(args ...string) (*tasks.Task, error) { if m.pm == "" { return nil, errors.New("no supported package manager on this host") } if !m.sudo { return nil, errors.New("needs root: run Ham Lab as root, or give its user passwordless sudo or doas") } if t, ok := m.tasks.RunningOf("updates"); ok { return nil, errors.New("another package task is running: " + t.Title) } full := priv.Wrap(append([]string{"env", "DEBIAN_FRONTEND=noninteractive", "APT_LISTCHANGES_FRONTEND=none", "LC_ALL=C"}, args...)...) cmd := exec.Command(full[0], full[1:]...) return m.tasks.Start("updates", strings.Join(args, " "), cmd) } func (m *Manager) Refresh() (*tasks.Task, error) { switch m.pm { case "apt": return m.start("apt-get", "update") case "dnf": return m.start("dnf", "-y", "makecache", "--refresh") case "zypper": return m.start("zypper", "--non-interactive", "refresh") case "pacman": return m.start("pacman", "-Sy", "--noconfirm") case "apk": return m.start("apk", "update") } return m.start() } // rolling reports a distro whose supported upgrade path is a full distribution upgrade. func rolling() bool { b, _ := os.ReadFile("/etc/os-release") return strings.Contains(string(b), "tumbleweed") || strings.Contains(string(b), "microos") } // Upgrade upgrades everything, or only the named packages. Nothing here can block on a // question: apt keeps the current config file on a conflict, the rest run non-interactive. func (m *Manager) Upgrade(pkgs []string) (*tasks.Task, error) { if err := checkNames(pkgs); err != nil { return nil, err } all := len(pkgs) == 0 var args []string switch m.pm { case "apt": args = []string{"apt-get", "-y", "-o", "Dpkg::Options::=--force-confdef", "-o", "Dpkg::Options::=--force-confold"} if all { args = append(args, "dist-upgrade") } else { args = append(args, "install", "--only-upgrade") } case "dnf": args = []string{"dnf", "-y", "upgrade"} case "zypper": args = []string{"zypper", "--non-interactive", "update"} if all && rolling() { args = []string{"zypper", "--non-interactive", "dist-upgrade"} } case "pacman": if !all { return nil, errors.New("pacman upgrades everything or nothing: Arch doesn't support partial upgrades") } args = []string{"pacman", "-Syu", "--noconfirm"} case "apk": args = []string{"apk", "upgrade"} if all { args = []string{"apk", "upgrade", "--update-cache"} } } return m.start(append(args, pkgs...)...) } // Install installs packages (used by other tabs for optional tooling like smartmontools). func (m *Manager) Install(pkgs []string) (*tasks.Task, error) { if len(pkgs) == 0 { return nil, errors.New("no packages") } if err := checkNames(pkgs); err != nil { return nil, err } var args []string switch m.pm { case "apt": args = []string{"apt-get", "-y", "--no-install-recommends", "install"} case "dnf": args = []string{"dnf", "-y", "install"} case "zypper": args = []string{"zypper", "--non-interactive", "install", "--no-recommends"} case "pacman": args = []string{"pacman", "-S", "--noconfirm", "--needed"} case "apk": args = []string{"apk", "add"} } return m.start(append(args, pkgs...)...) } func (m *Manager) Changelog(ctx context.Context, pkg string) (string, error) { if !pkgNameRe.MatchString(pkg) { return "", errors.New("bad package name") } switch m.pm { case "apt": text, err := out(ctx, "apt-get", "changelog", "-qq", pkg) if len(text) > 64<<10 { text = text[:64<<10] + "\n…" } return text, err case "dnf": return out(ctx, "dnf", "-q", "changelog", pkg) } return "", errors.New("changelogs not available for " + m.pm) } // Power runs systemctl reboot / poweroff. func (m *Manager) Power(ctx context.Context, action string) error { if action != "reboot" && action != "poweroff" { return errors.New("unknown power action") } _, err := out(ctx, m.sudoArgs("systemctl", action)...) return err }