// Package sysx — обёртки над системными командами и файлами Linux. package sysx import ( "bufio" "bytes" "context" "crypto/md5" "encoding/hex" "fmt" "io" "os" "os/exec" "strings" ) // Output выполняет команду и возвращает stdout без хвостовых пробелов. func Output(ctx context.Context, name string, args ...string) (string, error) { var stdout, stderr bytes.Buffer cmd := exec.CommandContext(ctx, name, args...) cmd.Stdout = &stdout cmd.Stderr = &stderr if err := cmd.Run(); err != nil { msg := strings.TrimSpace(stderr.String()) if msg == "" { msg = strings.TrimSpace(stdout.String()) } return "", fmt.Errorf("%s %s: %w: %s", name, strings.Join(args, " "), err, msg) } return strings.TrimSpace(stdout.String()), nil } // ToFile выполняет команду, направляя stdout в файл path. func ToFile(ctx context.Context, path string, name string, args ...string) error { f, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) if err != nil { return err } var stderr bytes.Buffer cmd := exec.CommandContext(ctx, name, args...) cmd.Stdout = f cmd.Stderr = &stderr runErr := cmd.Run() closeErr := f.Close() if runErr != nil { return fmt.Errorf("%s: %w: %s", name, runErr, strings.TrimSpace(stderr.String())) } return closeErr } // FromFile выполняет команду, подавая файл path на stdin. func FromFile(ctx context.Context, path string, name string, args ...string) error { f, err := os.Open(path) if err != nil { return err } defer f.Close() return Stream(ctx, f, name, args...) } // Stream выполняет команду с заданным stdin, вывод — в консоль. func Stream(ctx context.Context, stdin io.Reader, name string, args ...string) error { cmd := exec.CommandContext(ctx, name, args...) cmd.Stdin = stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { return fmt.Errorf("%s %s: %w", name, strings.Join(args, " "), err) } return nil } // Run выполняет команду с выводом в консоль. func Run(ctx context.Context, name string, args ...string) error { return Stream(ctx, nil, name, args...) } // Shell выполняет строку через bash -c с выводом в консоль. func Shell(ctx context.Context, script string, env ...string) error { cmd := exec.CommandContext(ctx, "bash", "-c", script) cmd.Env = append(os.Environ(), env...) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { return fmt.Errorf("bash -c %q: %w", script, err) } return nil } func Exists(p string) bool { _, err := os.Lstat(p) return err == nil } func HasCommand(name string) bool { _, err := exec.LookPath(name) return err == nil } // OSRelease читает /etc/os-release. func OSRelease() map[string]string { out := map[string]string{} f, err := os.Open("/etc/os-release") if err != nil { return out } defer f.Close() sc := bufio.NewScanner(f) for sc.Scan() { k, v, ok := strings.Cut(sc.Text(), "=") if !ok { continue } out[k] = strings.Trim(v, `"'`) } return out } // DpkgVersion возвращает установленную версию пакета или "". func DpkgVersion(ctx context.Context, pkg string) string { out, err := Output(ctx, "dpkg-query", "-W", "-f=${db:Status-Status} ${Version}", pkg) if err != nil { return "" } status, ver, _ := strings.Cut(out, " ") if status != "installed" { return "" } return ver } func DpkgArch(ctx context.Context) string { out, err := Output(ctx, "dpkg", "--print-architecture") if err != nil { return "" } return out } // UnitState возвращает (enabled, active) для systemd-юнита. func UnitState(ctx context.Context, unit string) (bool, bool) { en, _ := Output(ctx, "systemctl", "is-enabled", unit) ac, _ := Output(ctx, "systemctl", "is-active", unit) return en == "enabled", ac == "active" } // UnitLoaded сообщает, известен ли systemd такой юнит. func UnitLoaded(ctx context.Context, unit string) bool { out, err := Output(ctx, "systemctl", "show", "-p", "LoadState", "--value", unit) return err == nil && out == "loaded" } // GroupMembers возвращает членов группы из /etc/group. func GroupMembers(group string) []string { data, err := os.ReadFile("/etc/group") if err != nil { return nil } for _, line := range strings.Split(string(data), "\n") { f := strings.Split(line, ":") if len(f) == 4 && f[0] == group && f[3] != "" { return strings.Split(f[3], ",") } } return nil } // DpkgOwned возвращает множество путей, принадлежащих какому-либо пакету (dpkg -S). // Без dpkg (не Debian/Ubuntu) считает все пути своими — ничего не теряется. func DpkgOwned(ctx context.Context, paths []string) map[string]bool { owned := map[string]bool{} if len(paths) == 0 || !HasCommand("dpkg") { return owned } cmd := exec.CommandContext(ctx, "dpkg", append([]string{"-S"}, paths...)...) out, _ := cmd.Output() // код возврата 1, если хоть один путь ничей, — это нормально return ParseDpkgS(string(out), paths) } // ParseDpkgS разбирает вывод `dpkg -S`: "pkg1, pkg2: /path" или "diversion by ...". func ParseDpkgS(out string, paths []string) map[string]bool { want := map[string]bool{} for _, p := range paths { want[p] = true } owned := map[string]bool{} for _, line := range strings.Split(out, "\n") { if strings.HasPrefix(line, "diversion ") { continue } _, p, ok := strings.Cut(line, ": ") if p = strings.TrimSpace(p); ok && want[p] { owned[p] = true } } return owned } // Timezone возвращает часовой пояс системы (Europe/Moscow) или "". func Timezone(ctx context.Context) string { if tz, err := Output(ctx, "timedatectl", "show", "-p", "Timezone", "--value"); err == nil && tz != "" { return tz } if link, err := os.Readlink("/etc/localtime"); err == nil { if _, tz, ok := strings.Cut(link, "zoneinfo/"); ok { return tz } } return "" } // SwapFiles возвращает swap-файлы (не разделы) из /proc/swaps: путь и размер в байтах. func SwapFiles() map[string]int64 { out := map[string]int64{} data, err := os.ReadFile("/proc/swaps") if err != nil { return out } for _, line := range strings.Split(string(data), "\n")[1:] { f := strings.Fields(line) if len(f) < 3 || f[1] != "file" { continue } var kb int64 fmt.Sscan(f[2], &kb) out[f[0]] = kb * 1024 } return out } // ManualPackages — пакеты, поставленные вручную (apt-mark showmanual). func ManualPackages(ctx context.Context) []string { out, err := Output(ctx, "apt-mark", "showmanual") if err != nil { return nil } return strings.Fields(out) } // ModifiedConffiles возвращает конфиги пакетов (conffiles), изменённые относительно // поставки: md5 файла не совпадает с записанным dpkg. func ModifiedConffiles(ctx context.Context) []string { out, err := Output(ctx, "dpkg-query", "-W", `-f=${Conffiles}\n`) if err != nil { return nil } return modifiedConffiles(out, fileMD5) } func modifiedConffiles(out string, sum func(string) string) []string { var res []string seen := map[string]bool{} for _, line := range strings.Split(out, "\n") { f := strings.Fields(line) if len(f) < 2 || !strings.HasPrefix(f[0], "/") || seen[f[0]] { continue } if len(f) > 2 && (f[2] == "obsolete" || f[2] == "remove-on-upgrade") { continue } seen[f[0]] = true if s := sum(f[0]); s != "" && s != f[1] { res = append(res, f[0]) } } return res } func fileMD5(p string) string { f, err := os.Open(p) if err != nil { return "" } defer f.Close() h := md5.New() if _, err := io.Copy(h, f); err != nil { return "" } return hex.EncodeToString(h.Sum(nil)) }