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:
1 parent
916a49958b
commit
3d2f886f04
22 files changed
+1074
-7
No files matched your search
@@ -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})
|
||||
}
|
||||
Reference in new issue
Block a user