feat: Настройки › Автообновление - clients compare their version with the server's every N and update themselves; the server looks at GitLab every N and, when a newer release is out, offers it to an administrator at login (after the PIN) with «Обновить» and «Отказаться»; the words of a made picture's result are the model's only

This commit is contained in:
redtea committed 2026-10-18 12:33:24 +03:00
1 parent 916a49958b
commit 3d2f886f04
22 files changed
+1074 -7

No files matched your search

+389
View File
@@ -0,0 +1,389 @@
package server
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"github.com/x-grk/longpolling-remote-access/internal/proto"
)
// Auto-update, two kinds, each off until asked for (Настройки › Автообновление):
// - of the agents: they are told how often to compare their version with
// the server's and, if the server's is newer, to take the agent from it
// (as the panel's «Обновить» does);
// - of the server: it compares itself with the latest release on GitLab;
// when that is newer, an administrator is offered it at login (after the
// PIN), and on «Обновить» the install script is run (the same one as
// `wget -qO- …/server-install.sh | sudo sh`). Nothing runs unasked.
const (
cfgAgentAuto = "agent_autoupdate"
cfgAgentAutoMin = "agent_autoupdate_min"
cfgServerAuto = "server_autoupdate"
cfgServerAutoMin = "server_autoupdate_min"
// Where releases are. Not in the settings: for tests.
cfgUpdateRepo = "update_repo"
cfgUpdateBase = "update_base"
DefaultUpdateRepo = "itzredtea/lpra"
DefaultUpdateBase = "https://gitlab.com"
defaultAutoMin = 360 // 6 hours
maxAutoMin = 60 * 24 * 30 // 30 days
)
// autoSetting reads an "on" flag and its interval in minutes.
func (s *Server) autoSetting(onKey, minKey string) (bool, int) {
on, _ := s.db.GetConfig(onKey)
m, _ := strconv.Atoi(func() string { v, _ := s.db.GetConfig(minKey); return v }())
if m < 1 || m > maxAutoMin {
m = defaultAutoMin
}
return on == "1", m
}
func (s *Server) agentAuto() (bool, int) { return s.autoSetting(cfgAgentAuto, cfgAgentAutoMin) }
func (s *Server) serverAuto() (bool, int) { return s.autoSetting(cfgServerAuto, cfgServerAutoMin) }
// setUpdateHeaders tells an agent the server's version and how often to
// compare ("0": never).
func (s *Server) setUpdateHeaders(w http.ResponseWriter) {
w.Header().Set(proto.ServerVersionHeader, Version)
w.Header().Set(proto.AutoUpdateHeader, strconv.Itoa(s.agentAutoMinutes()))
}
// agentAutoMinutes is the interval agents are given, 0 when off.
func (s *Server) agentAutoMinutes() int {
if on, m := s.agentAuto(); on {
return m
}
return 0
}
// minutesOf turns "2" and "h" into 120: min, h, d.
func minutesOf(n, unit string) int {
v, err := strconv.Atoi(strings.TrimSpace(n))
if err != nil || v < 1 {
return defaultAutoMin
}
switch unit {
case "h":
v *= 60
case "d":
v *= 60 * 24
}
if v > maxAutoMin {
v = maxAutoMin
}
return v
}
// POST /settings/autoupdate - both settings.
func (s *Server) handleSetAutoUpdate(w http.ResponseWriter, r *http.Request) {
if !checkCSRF(r) {
http.Error(w, "bad csrf", http.StatusForbidden)
return
}
flag := func(v string) string {
if v == "1" {
return "1"
}
return ""
}
_ = s.db.SetConfig(cfgAgentAuto, flag(r.FormValue("agent_auto")))
_ = s.db.SetConfig(cfgAgentAutoMin, strconv.Itoa(minutesOf(r.FormValue("agent_every"), r.FormValue("agent_unit"))))
_ = s.db.SetConfig(cfgServerAuto, flag(r.FormValue("server_auto")))
_ = s.db.SetConfig(cfgServerAutoMin, strconv.Itoa(minutesOf(r.FormValue("server_every"), r.FormValue("server_unit"))))
// A server that was told to look again looks at once.
s.upd.mu.Lock()
s.upd.last = time.Time{}
s.upd.mu.Unlock()
http.Redirect(w, r, "/settings#autoupdate", http.StatusSeeOther)
}
// intervalParts is a number of minutes as the form shows it: 120 - 2 h.
func intervalParts(min int) (int, string) {
switch {
case min%(60*24) == 0:
return min / (60 * 24), "d"
case min%60 == 0:
return min / 60, "h"
}
return min, "min"
}
// autoView is what Настройки show of the settings.
type autoView struct {
AgentOn, ServerOn bool
AgentEvery, ServerEvery int
AgentUnit, ServerUnit string
Status updStatus
}
func (s *Server) autoView() autoView {
var v autoView
var am, sm int
v.AgentOn, am = s.agentAuto()
v.ServerOn, sm = s.serverAuto()
v.AgentEvery, v.AgentUnit = intervalParts(am)
v.ServerEvery, v.ServerUnit = intervalParts(sm)
v.Status = s.updateStatus()
return v
}
// updState is what the updating of the server remembers.
type updState struct {
mu sync.Mutex
last time.Time // when it last looked
latest string
checked time.Time
err string
}
// updStatus is that, for the settings page.
type updStatus struct {
Current, Latest string
Checked string
Error string
Newer bool
}
func (s *Server) updateStatus() updStatus {
s.upd.mu.Lock()
defer s.upd.mu.Unlock()
st := updStatus{Current: Version, Latest: s.upd.latest, Error: s.upd.err, Newer: proto.VersionNewer(s.upd.latest, Version)}
if !s.upd.checked.IsZero() {
st.Checked = s.upd.checked.Format("02.01.2006 15:04")
}
return st
}
func (s *Server) updateRepoBase() (base, repo string) {
base, _ = s.db.GetConfig(cfgUpdateBase)
if base == "" {
base = DefaultUpdateBase
}
repo, _ = s.db.GetConfig(cfgUpdateRepo)
if repo == "" {
repo = DefaultUpdateRepo
}
return strings.TrimRight(base, "/"), repo
}
var updateClient = &http.Client{Timeout: 30 * time.Second}
// latestRelease is the tag of the project's latest release on GitLab.
func (s *Server) latestRelease(ctx context.Context) (string, error) {
base, repo := s.updateRepoBase()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, base+"/api/v4/projects/"+url.PathEscape(repo)+"/releases/permalink/latest", nil)
req.Header.Set("Accept", "application/json")
resp, err := updateClient.Do(req)
if err != nil {
return "", errors.New("GitLab не отвечает: " + err.Error())
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
return "", errors.New("GitLab ответил " + resp.Status)
}
var out struct {
Tag string `json:"tag_name"`
}
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&out); err != nil || out.Tag == "" {
return "", errors.New("не прочиталась версия последнего релиза")
}
return strings.TrimPrefix(out.Tag, "v"), nil
}
// checkRelease looks at GitLab and keeps what it found for the page.
func (s *Server) checkRelease(ctx context.Context) (string, error) {
tag, err := s.latestRelease(ctx)
s.upd.mu.Lock()
defer s.upd.mu.Unlock()
s.upd.checked = time.Now()
if err != nil {
s.upd.err = err.Error()
return "", err
}
s.upd.latest, s.upd.err = tag, ""
return tag, nil
}
// installScript is the install script on the project's main branch.
func (s *Server) installScript(ctx context.Context) ([]byte, error) {
base, repo := s.updateRepoBase()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, base+"/"+repo+"/-/raw/main/server-install.sh", nil)
resp, err := updateClient.Do(req)
if err != nil {
return nil, errors.New("скрипт установки не скачался: " + err.Error())
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
return nil, errors.New("скрипт установки: GitLab ответил " + resp.Status)
}
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
// What is run as root must be the script, not a page of an error.
if !strings.HasPrefix(string(b), "#!/bin/sh\n") || !strings.Contains(string(b), "LPRA server") {
return nil, errors.New("по адресу скрипта установки лежит не он")
}
return b, nil
}
// startServerUpdate takes the install script and has it run, apart from
// this process: it stops the service, and a script in the service's own
// control group would go with it.
func (s *Server) startServerUpdate(ctx context.Context) error {
script, err := s.installScript(ctx)
if err != nil {
return err
}
run := s.updRun
if run == nil {
run = runServerUpdate
}
return run(script)
}
// runServerUpdate runs the script as a transient systemd unit of its own,
// in the folder this server lives in. Needs root and systemd.
func runServerUpdate(script []byte) error {
if os.Geteuid() != 0 {
return errors.New("сервер запущен не от root - обновить его самому нельзя, запустите server-install.sh")
}
sr, err := exec.LookPath("systemd-run")
if err != nil {
return errors.New("нет systemd-run - автообновление сервера работает только под systemd")
}
exe, err := os.Executable()
if err != nil {
return err
}
dir := filepath.Dir(exe)
path := filepath.Join(dir, ".server-update.sh")
if err := os.WriteFile(path, script, 0o700); err != nil {
return errors.New("скрипт не записался: " + err.Error())
}
out, err := exec.Command(sr, "--unit=lpra-self-update", "--collect", "--setenv=LPRA_DIR="+dir,
"/bin/sh", "-c", "sh '"+path+"'; rm -f '"+path+"'").CombinedOutput()
if err != nil {
_ = os.Remove(path)
return fmt.Errorf("systemd-run: %s", strings.TrimSpace(string(out)))
}
return nil
}
// autoUpdateTick: one look at the clock. When the server is to look at
// GitLab (on, and the interval is out) it does, and keeps what it found.
func (s *Server) autoUpdateTick(now time.Time) {
on, min := s.serverAuto()
if !on {
return
}
s.upd.mu.Lock()
if !s.upd.last.IsZero() && now.Sub(s.upd.last) < time.Duration(min)*time.Minute {
s.upd.mu.Unlock()
return
}
s.upd.last = now
s.upd.mu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
_, _ = s.checkRelease(ctx)
}
// RunAutoUpdate watches the clock until ctx ends.
func (s *Server) RunAutoUpdate(ctx context.Context) {
t := time.NewTicker(30 * time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case now := <-t.C:
s.autoUpdateTick(now)
}
}
}
// POST /api/panel/update/check - looks at GitLab now.
func (s *Server) handleUpdateCheck(w http.ResponseWriter, r *http.Request) {
tag, err := s.checkRelease(r.Context())
if err != nil {
writeJSON(w, map[string]any{"ok": false, "error": err.Error(), "current": Version})
return
}
writeJSON(w, map[string]any{"ok": true, "current": Version, "latest": tag, "newer": proto.VersionNewer(tag, Version)})
}
// POST /api/panel/update/run - updates the server now, to a newer release.
func (s *Server) handleUpdateRun(w http.ResponseWriter, r *http.Request) {
tag, err := s.checkRelease(r.Context())
if err != nil {
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
return
}
if !proto.VersionNewer(tag, Version) {
writeJSON(w, map[string]any{"ok": false, "error": "уже последняя версия (" + Version + ")"})
return
}
if err := s.startServerUpdate(r.Context()); err != nil {
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
return
}
writeJSON(w, map[string]any{"ok": true, "latest": tag})
}
func declinedKey(id int64) string { return "update_declined_" + strconv.FormatInt(id, 10) }
// GET /api/panel/update/offer - whether this administrator is to be offered
// an update now: auto-update on, a release newer than this one, and not one
// they declined.
func (s *Server) handleUpdateOffer(w http.ResponseWriter, r *http.Request) {
no := func() { writeJSON(w, map[string]any{"offer": false}) }
on, min := s.serverAuto()
if !on {
no()
return
}
// Not looked at lately (just started, or the interval is out): look now.
s.upd.mu.Lock()
stale := s.upd.checked.IsZero() || time.Since(s.upd.checked) >= time.Duration(min)*time.Minute
s.upd.mu.Unlock()
if stale {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
_, _ = s.checkRelease(ctx)
cancel()
}
st := s.updateStatus()
declined, _ := s.db.GetConfig(declinedKey(s.currentAdmin(r).ID))
if !st.Newer || declined == st.Latest {
no()
return
}
writeJSON(w, map[string]any{"offer": true, "current": st.Current, "latest": st.Latest})
}
// POST /api/panel/update/decline - {version}: this administrator is not to
// be asked about that release again.
func (s *Server) handleUpdateDecline(w http.ResponseWriter, r *http.Request) {
var in struct {
Version string `json:"version"`
}
if json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<10)).Decode(&in) == nil && len(in.Version) <= 32 {
_ = s.db.SetConfig(declinedKey(s.currentAdmin(r).ID), in.Version)
}
writeJSON(w, map[string]any{"ok": true})
}