// Command bj-component-install — root-установщик компонентов bj (независимые обновления). // // bj-server работает без прав root: он скачивает подписанный манифест релиза и выбранные файлы в // каталог подготовки и просит root-воркер (bj-keymedia-worker, действие component-install) вызвать // этот инструмент. Установщик ничему из каталога не доверяет: сам проверяет подпись манифеста // публичным ключом из /etc/bj/update.pub (принадлежит root) и sha256 каждого файла, ставит только // известные компоненты (release.Components) по их путям, не откатывает на более старую версию, // сохраняет прежний файл (<путь>.bak-ДАТА, хранятся 5 последних), перезапускает службы (ядро // bj-server последним) и записывает установленные версии в /var/lib/bj/.bj/components.json. // Вывод — JSON для bj-server. // // bj-component-install <каталог подготовки> // в каталоге: manifest.json (подписанный), request.json {"names": [...]}, файлы компонентов // bj-component-install -root /tmp/песочница -pub test.pub -no-restart <каталог> (проверка на копии) package main import ( "encoding/json" "errors" "flag" "fmt" "io" "os" "os/exec" "path/filepath" "sort" "strings" "syscall" "time" "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/release" ) const stagingBase = "/var/lib/bj/update-staging" type result struct { OK bool `json:"ok"` Error string `json:"error,omitempty"` Release string `json:"release,omitempty"` Installed []map[string]string `json:"installed,omitempty"` Restarted []string `json:"restarted,omitempty"` } func main() { fs := flag.NewFlagSet("bj-component-install", flag.ExitOnError) root := fs.String("root", "", "корень установки (для проверки в песочнице; по умолчанию /)") pubPath := fs.String("pub", "/etc/bj/update.pub", "публичный ключ издателя (base64 Ed25519), файл root") noRestart := fs.Bool("no-restart", false, "не перезапускать службы") _ = fs.Parse(os.Args[1:]) res := run(*root, *pubPath, !*noRestart, fs.Arg(0)) b, _ := json.Marshal(res) fmt.Println(string(b)) if !res.OK { os.Exit(1) } } func run(root, pubPath string, restart bool, staging string) (res result) { fail := func(format string, a ...any) result { res.OK, res.Error = false, fmt.Sprintf(format, a...) return res } if staging == "" { return fail("не указан каталог подготовки") } staging = filepath.Clean(staging) if root == "" && !strings.HasPrefix(staging, stagingBase+"/") { return fail("каталог подготовки должен быть внутри %s", stagingBase) } pub, err := release.LoadPublicKey(pubPath) if err != nil { return fail("публичный ключ обновлений (%s): %v", pubPath, err) } var sm release.SignedManifest if err := readJSON(staging, "manifest.json", &sm); err != nil { return fail("манифест: %v", err) } m, err := release.Verify(&sm, pub) if err != nil { return fail("подпись манифеста не прошла проверку: %v", err) } res.Release = m.Version var req struct { Names []string `json:"names"` } if err := readJSON(staging, "request.json", &req); err != nil { return fail("запрос: %v", err) } if len(req.Names) == 0 { return fail("не выбраны компоненты") } installedPath := release.InstalledFile if root != "" { installedPath = filepath.Join(root, installedPath) } installed := release.LoadInstalled(installedPath) // Проверки до любой установки: всё или ничего. type job struct { c release.Component a release.Artifact file string } var jobs []job for _, name := range req.Names { c, ok := release.ComponentByName(name) if !ok { return fail("неизвестный компонент %q", name) } var art *release.Artifact for i := range m.Artifacts { if m.Artifacts[i].Name == name { art = &m.Artifacts[i] } } if art == nil { return fail("компонента %s нет в релизе %s", name, m.Version) } ver := art.Version if ver == "" { ver = m.Version } art.Version = ver if _, err := os.Stat(c.PathAt(root)); err != nil && !c.Core { return fail("%s не установлен на этом сервере — модуль ставится установщиком, а не обновлением", name) } if prev, ok := installed[name]; ok && prev.Version != "" && release.IsNewer(prev.Version, ver) { return fail("%s: установлена %s, в релизе более старая %s — откат не выполняется", name, prev.Version, ver) } file := filepath.Join(staging, filepath.Base(art.File)) fi, err := os.Lstat(file) if err != nil || !fi.Mode().IsRegular() { return fail("%s: файл %s не найден в каталоге подготовки", name, art.File) } if err := release.VerifyArtifact(file, *art); err != nil { return fail("%s: %v", name, err) } jobs = append(jobs, job{c: c, a: *art, file: file}) } // Порядок как в release.Components: ядро первым в списке ставится, но перезапускается последним. sort.SliceStable(jobs, func(i, j int) bool { return order(jobs[i].c.Name) < order(jobs[j].c.Name) }) ts := time.Now().Format("20060102-150405") var units []string for _, j := range jobs { dst := j.c.PathAt(root) if err := install(j.file, dst, j.c.Exec, ts); err != nil { return fail("установка %s: %v", j.c.Name, err) } installed[j.c.Name] = release.Installed{Version: j.a.Version, SHA256: j.a.SHA256, InstalledAt: time.Now().UTC()} res.Installed = append(res.Installed, map[string]string{"name": j.c.Name, "version": j.a.Version}) if j.c.Unit != "" { units = append(units, j.c.Unit) } } if err := release.SaveInstalled(installedPath, installed); err != nil { return fail("запись установленных версий: %v", err) } chownLikeParent(installedPath) if restart { sort.SliceStable(units, func(i, j int) bool { return units[j] == "bj-server" && units[i] != "bj-server" }) for _, u := range units { if out, err := exec.Command("systemctl", "restart", u).CombinedOutput(); err != nil { return fail("перезапуск %s: %v: %s", u, err, strings.TrimSpace(string(out))) } res.Restarted = append(res.Restarted, u) } } res.OK = true return res } func order(name string) int { for i, c := range release.Components { if c.Name == name { return i } } return len(release.Components) } func readJSON(dir, name string, v any) error { p := filepath.Join(dir, name) fi, err := os.Lstat(p) if err != nil { return err } if !fi.Mode().IsRegular() || fi.Size() > 1<<20 { return errors.New(name + ": не обычный файл или слишком большой") } b, err := os.ReadFile(p) if err != nil { return err } return json.Unmarshal(b, v) } // install — копия прежнего файла, запись нового рядом и атомарная замена. func install(src, dst string, execBit bool, ts string) error { if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { return err } if _, err := os.Stat(dst); err == nil { if err := copyFile(dst, dst+".bak-"+ts, 0o755); err != nil { return fmt.Errorf("копия прежнего файла: %w", err) } pruneBackups(dst, 5) } mode := os.FileMode(0o644) if execBit { mode = 0o755 } tmp := dst + ".new" if err := copyFile(src, tmp, mode); err != nil { return err } return os.Rename(tmp, dst) } func copyFile(src, dst string, mode os.FileMode) error { in, err := os.Open(src) if err != nil { return err } defer in.Close() out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode) if err != nil { return err } if _, err := io.Copy(out, in); err != nil { out.Close() return err } if err := out.Sync(); err != nil { out.Close() return err } if err := out.Close(); err != nil { return err } return os.Chmod(dst, mode) } // pruneBackups — хранить keep последних копий <путь>.bak-ДАТА-ВРЕМЯ. func pruneBackups(dst string, keep int) { files, _ := filepath.Glob(dst + ".bak-2*") sort.Strings(files) for len(files) > keep { _ = os.Remove(files[0]) files = files[1:] } } func chownLikeParent(path string) { if fi, err := os.Stat(filepath.Dir(path)); err == nil { if s, ok := fi.Sys().(*syscall.Stat_t); ok { _ = os.Chown(path, int(s.Uid), int(s.Gid)) } } }