package packages import ( "context" "os/exec" "regexp" "strings" "nadir/internal/oscmd" "github.com/danielgtaylor/huma/v2" "github.com/danielgtaylor/huma/v2/sse" ) const tagPackages = "Packages" var ( readErrors = []int{401, 403, 500} writeErrors = []int{400, 401, 403, 500} ) // pkgNameRe matches a plain package name (no version specifiers, no shell // metacharacters). Combined with a leading-dash reject and "--" before the name // on every command line, it keeps user input from being read as a flag. var pkgNameRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9@._+-]*$`) // manager identifies the host package manager. Its name drives a switch in each // operation rather than an interface - three concrete tools, no polymorphism. type manager struct{ name string } // detect picks the package manager. dnf/pacman are checked before apt because a // few rpm-based boxes also ship an apt-get shim; the native tool should win. func detect() manager { for _, m := range []struct{ name, bin string }{ {"dnf", "dnf"}, {"pacman", "pacman"}, {"apt", "apt-get"}, } { if _, err := exec.LookPath(m.bin); err == nil { return manager{name: m.name} } } return manager{} } type Package struct { Name string `json:"name" example:"openssh-server" doc:"Package name"` Version string `json:"version" example:"9.6p1" doc:"Installed version, or the available version for updates"` } type ListOutput struct { Body struct { Manager string `json:"manager" example:"dnf" doc:"Detected package manager"` Packages []Package `json:"packages"` } } type InstallInput struct { Body struct { Name string `json:"name" example:"htop" doc:"Package to install"` } } type RemoveInput struct { Name string `path:"name" example:"htop" doc:"Package to remove"` } type UpgradeOneInput struct { Name string `path:"name" example:"htop" doc:"Package to upgrade"` } // SSE event types for streaming package operations. type PkgOutputEvent struct { Line string `json:"line" doc:"One line of the package manager's terminal output"` } type PkgErrorEvent struct { Message string `json:"message"` } type PkgDoneEvent struct { Success bool `json:"success" doc:"True if the package manager exited 0"` Error string `json:"error,omitempty" doc:"Exit error when it failed"` } // pkgSem limits concurrent package manager operations. Package managers are // heavy (dnf/apt/pacman grab a lock), so running more than a few in parallel // just thrashes; 3 concurrent ops is generous for an admin panel. var pkgSem = make(chan struct{}, 3) // pkgEvents maps SSE event names to their payload types for the streaming // install/remove/upgrade operations. var pkgEvents = map[string]any{ "output": PkgOutputEvent{}, "done": PkgDoneEvent{}, "error": PkgErrorEvent{}, } func registerPackages(api huma.API, pm manager) { huma.Register(api, huma.Operation{ OperationID: "packages-list-installed", Method: "GET", Path: "/api/packages", Summary: "List installed packages", Tags: []string{tagPackages}, Metadata: op("read"), Errors: readErrors, }, func(ctx context.Context, _ *struct{}) (*ListOutput, error) { return listInstalled(pm) }) huma.Register(api, huma.Operation{ OperationID: "packages-list-updates", Method: "GET", Path: "/api/packages/updates", Summary: "List available updates", Tags: []string{tagPackages}, Metadata: op("read"), Errors: readErrors, }, func(ctx context.Context, _ *struct{}) (*ListOutput, error) { return listUpdates(pm) }) // Install/remove/upgrade stream the package manager's terminal output live // via SSE (one `output` event per line, a final `done` with exit status), // rather than blocking the request until a long operation finishes. sse.Register(api, huma.Operation{ OperationID: "packages-install", Method: "POST", Path: "/api/packages", Summary: "Install a package (streamed)", Description: "Installs a package, streaming the package manager's output as " + "`output` events and ending with a `done` event carrying the exit status.", Tags: []string{tagPackages}, Metadata: op("write"), }, pkgEvents, func(ctx context.Context, in *InstallInput, send sse.Sender) { if validateName(in.Body.Name) != nil { send.Data(PkgErrorEvent{Message: "invalid package name: " + in.Body.Name}) return } bin, args := pm.installArgs(in.Body.Name) streamOp(ctx, send, bin, args) }) sse.Register(api, huma.Operation{ OperationID: "packages-remove", Method: "DELETE", Path: "/api/packages/{name}", Summary: "Remove a package (streamed)", Tags: []string{tagPackages}, Metadata: op("write"), }, pkgEvents, func(ctx context.Context, in *RemoveInput, send sse.Sender) { if validateName(in.Name) != nil { send.Data(PkgErrorEvent{Message: "invalid package name: " + in.Name}) return } bin, args := pm.removeArgs(in.Name) streamOp(ctx, send, bin, args) }) sse.Register(api, huma.Operation{ OperationID: "packages-upgrade", Method: "POST", Path: "/api/packages/upgrade", Summary: "Upgrade all packages (streamed)", Description: "Upgrades every package to its latest version, streaming the " + "package manager's output live.", Tags: []string{tagPackages}, Metadata: op("write"), }, pkgEvents, func(ctx context.Context, _ *struct{}, send sse.Sender) { bin, args := pm.upgradeArgs() streamOp(ctx, send, bin, args) }) sse.Register(api, huma.Operation{ OperationID: "packages-upgrade-one", Method: "POST", Path: "/api/packages/upgrade/{name}", Summary: "Upgrade a single package (streamed)", Description: "Upgrades the named package to its latest version, streaming the " + "package manager's output live. apt uses `install --only-upgrade` so the " + "package must already be installed; dnf/pacman handle this natively.", Tags: []string{tagPackages}, Metadata: op("write"), }, pkgEvents, func(ctx context.Context, in *UpgradeOneInput, send sse.Sender) { if validateName(in.Name) != nil { send.Data(PkgErrorEvent{Message: "invalid package name: " + in.Name}) return } bin, args := pm.upgradeOneArgs(in.Name) streamOp(ctx, send, bin, args) }) } // streamOp runs a package write and streams its combined output to the client. func streamOp(ctx context.Context, send sse.Sender, bin string, args []string) { if bin == "" { send.Data(PkgErrorEvent{Message: "no supported package manager found"}) return } select { case pkgSem <- struct{}{}: default: send.Data(PkgErrorEvent{Message: "too many concurrent package operations, try again later"}) return } defer func() { <-pkgSem }() // DEBIAN_FRONTEND keeps apt from blocking on an interactive prompt. lines, errc, err := oscmd.RunStreamCombined(ctx, []string{"DEBIAN_FRONTEND=noninteractive"}, bin, args...) if err != nil { send.Data(PkgErrorEvent{Message: err.Error()}) return } for line := range lines { if send.Data(PkgOutputEvent{Line: line}) != nil { return // client gone; ctx cancel kills the process } } done := PkgDoneEvent{Success: true} if werr := <-errc; werr != nil { done.Success = false done.Error = werr.Error() } send.Data(done) } func validateName(name string) error { if !pkgNameRe.MatchString(name) { return huma.Error400BadRequest("invalid package name: " + name) } return nil } // --- reads ------------------------------------------------------------------- func listInstalled(pm manager) (*ListOutput, error) { var out string var err error switch pm.name { case "dnf": out, err = oscmd.Run("rpm", "-qa", "--qf", "%{NAME}\t%{VERSION}-%{RELEASE}\n") case "apt": out, err = oscmd.Run("dpkg-query", "-W", "-f=${Package}\t${Version}\n") case "pacman": out, err = oscmd.Run("pacman", "-Q") default: return nil, huma.Error500InternalServerError("no supported package manager found") } if err != nil { return nil, huma.Error500InternalServerError("listing installed packages failed", err) } pkgs := parseTabbed(out) if pm.name == "pacman" { pkgs = parseSpaced(out) } return result(pm, pkgs), nil } func listUpdates(pm manager) (*ListOutput, error) { switch pm.name { case "dnf": // check-update exits 100 when updates exist, 0 when none - both fine. out, code, err := oscmd.RunStatus("dnf", "-q", "check-update") if err != nil || (code != 0 && code != 100) { return nil, huma.Error500InternalServerError("dnf check-update failed", err) } return result(pm, parseDnf(out)), nil case "apt": out, err := oscmd.Run("apt", "list", "--upgradable", "-qq") if err != nil { return nil, huma.Error500InternalServerError("apt list failed", err) } return result(pm, parseApt(out)), nil case "pacman": // -Qu exits 1 when there is nothing to upgrade - not an error. out, code, err := oscmd.RunStatus("pacman", "-Qu") if err != nil || (code != 0 && code != 1) { return nil, huma.Error500InternalServerError("pacman -Qu failed", err) } return result(pm, parsePacmanUpdates(out)), nil default: return nil, huma.Error500InternalServerError("no supported package manager found") } } func result(pm manager, pkgs []Package) *ListOutput { out := &ListOutput{} out.Body.Manager = pm.name out.Body.Packages = pkgs return out } // --- writes ------------------------------------------------------------------ func (m manager) installArgs(name string) (string, []string) { switch m.name { case "dnf": return "dnf", []string{"install", "-y", name} case "apt": return "apt-get", []string{"install", "-y", name} case "pacman": return "pacman", []string{"-S", "--noconfirm", name} } return "", nil } func (m manager) removeArgs(name string) (string, []string) { switch m.name { case "dnf": return "dnf", []string{"remove", "-y", name} case "apt": return "apt-get", []string{"remove", "-y", name} case "pacman": return "pacman", []string{"-R", "--noconfirm", name} } return "", nil } func (m manager) upgradeArgs() (string, []string) { switch m.name { case "dnf": return "dnf", []string{"upgrade", "-y"} case "apt": return "apt-get", []string{"upgrade", "-y"} case "pacman": return "pacman", []string{"-Su", "--noconfirm"} } return "", nil } // upgradeOneArgs upgrades a single package to its latest version. apt's // `install --only-upgrade` is the safe variant (won't install if absent); // pacman -S re-syncs to latest; dnf upgrade is naturally scoped by name. func (m manager) upgradeOneArgs(name string) (string, []string) { switch m.name { case "dnf": return "dnf", []string{"upgrade", "-y", name} case "apt": return "apt-get", []string{"install", "--only-upgrade", "-y", name} case "pacman": return "pacman", []string{"-S", "--noconfirm", name} } return "", nil } // --- parsers (pure, tested) -------------------------------------------------- // parseTabbed reads "name\tversion" lines (dpkg-query / rpm output). func parseTabbed(out string) []Package { pkgs := []Package{} for line := range strings.SplitSeq(out, "\n") { if name, ver, ok := strings.Cut(line, "\t"); ok && name != "" { pkgs = append(pkgs, Package{Name: name, Version: ver}) } } return pkgs } // parseSpaced reads "name version" lines (pacman -Q). func parseSpaced(out string) []Package { pkgs := []Package{} for line := range strings.SplitSeq(out, "\n") { if f := strings.Fields(line); len(f) >= 2 { pkgs = append(pkgs, Package{Name: f[0], Version: f[1]}) } } return pkgs } // parseDnf reads `dnf check-update` lines ("name.arch version repo"), skipping // the section headers and blank lines that dnf5 emits. func parseDnf(out string) []Package { pkgs := []Package{} for line := range strings.SplitSeq(out, "\n") { f := strings.Fields(line) // Real rows have 3 columns and an arch suffix on the name; headers like // "Upgrades" or "Obsoleting Packages" don't. if len(f) != 3 || !strings.Contains(f[0], ".") { continue } pkgs = append(pkgs, Package{Name: stripArch(f[0]), Version: f[1]}) } return pkgs } // parseApt reads `apt list --upgradable` lines ("name/repo version arch [...]"). func parseApt(out string) []Package { pkgs := []Package{} for line := range strings.SplitSeq(out, "\n") { f := strings.Fields(line) if len(f) < 2 || !strings.Contains(f[0], "/") { continue } name, _, _ := strings.Cut(f[0], "/") pkgs = append(pkgs, Package{Name: name, Version: f[1]}) } return pkgs } // parsePacmanUpdates reads `pacman -Qu` lines ("name oldver -> newver"). func parsePacmanUpdates(out string) []Package { pkgs := []Package{} for line := range strings.SplitSeq(out, "\n") { f := strings.Fields(line) if len(f) < 4 || f[2] != "->" { continue } pkgs = append(pkgs, Package{Name: f[0], Version: f[3]}) } return pkgs } // stripArch removes the trailing ".arch" from an rpm "name.arch" token. The // arch is always the final dotted segment, so cut at the last dot. func stripArch(s string) string { if i := strings.LastIndex(s, "."); i > 0 { return s[:i] } return s }