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}) }