diff --git a/README.md b/README.md
index ec62a79..2af1cd0 100644
--- a/README.md
+++ b/README.md
@@ -67,6 +67,13 @@
не расшифровать.
- **Проброс портов** - `http` через панель, `tcp`/`udp` через прямой туннель.
- **Статус** - нагрузка, память, диски, сеть, тест скорости канала до сервера.
+- **Автообновление** («Настройки › Автообновление», обе части выключены, пока не включены):
+ *клиентов* - агенты раз в N минут/часов/суток сверяют свою версию с версией сервера и, если у сервера новее,
+ обновляются сами (как по кнопке «Обновить»; нужен root или sudo без пароля; агентам до 0.2.5 один раз обновиться
+ придётся вручную); *сервера* - сервер раз в N сверяет себя с последним релизом на GitLab, и когда вышел новый,
+ администратору при входе (после PIN) показывается окно «Обновить / Отказаться» - отказ запоминается для этой
+ версии. «Обновить» запускает `server-install.sh` отдельной службой systemd (нужны root и systemd); там же кнопки
+ «Проверить сейчас» и «Обновить сейчас».
- **Вход** - пароль и/или ключи доступа (passkeys); пароль можно выключить, когда есть ключ.
- **PIN-код** (необязательный, 4 цифры) - спрашивается в каждой новой вкладке браузера, но не при
перезагрузке. На устройстве можно не спрашивать его 5/15/30/60 минут после ввода.
diff --git a/cmd/server/main.go b/cmd/server/main.go
index 6c0c3a6..c80eab9 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -6,6 +6,7 @@
package main
import (
+ "context"
"flag"
"log"
"net/http"
@@ -42,6 +43,9 @@ func main() {
log.Fatalf("init server: %v", err)
}
+ // The server updates itself, if told to (Настройки › Автообновление).
+ go srv.RunAutoUpdate(context.Background())
+
httpSrv := &http.Server{
Addr: *addr,
Handler: srv.Handler(),
diff --git a/internal/agent/autoupdate.go b/internal/agent/autoupdate.go
new file mode 100644
index 0000000..25aa917
--- /dev/null
+++ b/internal/agent/autoupdate.go
@@ -0,0 +1,95 @@
+package agent
+
+import (
+ "context"
+ "log"
+ "net/http"
+ "strconv"
+ "sync"
+ "time"
+
+ "github.com/x-grk/longpolling-remote-access/internal/proto"
+)
+
+// Auto-update, the agent's part: the server tells (with every poll, on its
+// config and on the WebSocket) its own version and how often to compare -
+// in minutes, 0 for never. When the server's version is newer, the agent
+// takes the new one from it, as the panel's «Обновить» does.
+
+// autoUpdate is what the agent was told, and when it last looked.
+type autoUpdate struct {
+ mu sync.Mutex
+ minutes int
+ serverVer string
+ last time.Time
+}
+
+func (a *Agent) applyUpdateInfo(minutes int, serverVersion string) {
+ a.auto.mu.Lock()
+ defer a.auto.mu.Unlock()
+ if minutes < 0 {
+ minutes = 0
+ }
+ a.auto.minutes = minutes
+ if serverVersion != "" {
+ a.auto.serverVer = serverVersion
+ }
+}
+
+// applyUpdateHeaders takes the settings from the headers of an answer; an
+// answer without them (an older server) changes nothing.
+func (a *Agent) applyUpdateHeaders(h http.Header) {
+ v := h.Get(proto.AutoUpdateHeader)
+ if v == "" {
+ return
+ }
+ m, err := strconv.Atoi(v)
+ if err != nil {
+ m = 0
+ }
+ a.applyUpdateInfo(m, h.Get(proto.ServerVersionHeader))
+}
+
+func (a *Agent) updateInfo() (int, string) {
+ a.auto.mu.Lock()
+ defer a.auto.mu.Unlock()
+ return a.auto.minutes, a.auto.serverVer
+}
+
+func (a *Agent) autoUpdateOn() bool { m, _ := a.updateInfo(); return m > 0 }
+
+// autoUpdateTick: one look at the clock. Out of the interval since the last
+// look, it looks; the server's version newer than its own, it updates.
+// Reports whether it did.
+func (a *Agent) autoUpdateTick(now time.Time, update func() proto.TaskResult) bool {
+ a.auto.mu.Lock()
+ if a.auto.minutes <= 0 || (!a.auto.last.IsZero() && now.Sub(a.auto.last) < time.Duration(a.auto.minutes)*time.Minute) {
+ a.auto.mu.Unlock()
+ return false
+ }
+ a.auto.last = now
+ server := a.auto.serverVer
+ a.auto.mu.Unlock()
+ if !proto.VersionNewer(server, Version) {
+ return false
+ }
+ log.Printf("auto-update: server has %s, this is %s - updating", server, Version)
+ if res := update(); !res.OK {
+ log.Printf("auto-update: %s", res.Error)
+ }
+ return true
+}
+
+// autoUpdateLoop watches the clock until ctx ends.
+func (a *Agent) autoUpdateLoop(ctx context.Context) {
+ t := time.NewTicker(30 * time.Second)
+ defer t.Stop()
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case now := <-t.C:
+ a.autoUpdateTick(now, a.doUpdate)
+ }
+ }
+}
diff --git a/internal/agent/autoupdate_test.go b/internal/agent/autoupdate_test.go
new file mode 100644
index 0000000..e06df3c
--- /dev/null
+++ b/internal/agent/autoupdate_test.go
@@ -0,0 +1,78 @@
+package agent
+
+import (
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/x-grk/longpolling-remote-access/internal/proto"
+)
+
+// updates counts what an agent was made to update with.
+type updates struct{ n int }
+
+func (u *updates) do() proto.TaskResult { u.n++; return proto.TaskResult{OK: true} }
+
+// The agent is told, by headers or by the WebSocket's message, how often to
+// compare versions; nothing told, nothing done.
+func TestAutoUpdateIsTold(t *testing.T) {
+ a := New(Config{})
+ if a.autoUpdateOn() {
+ t.Fatal("on with nothing told")
+ }
+ h := http.Header{}
+ h.Set(proto.AutoUpdateHeader, "90")
+ h.Set(proto.ServerVersionHeader, "9.9.9")
+ a.applyUpdateHeaders(h)
+ if m, v := a.updateInfo(); m != 90 || v != "9.9.9" {
+ t.Fatalf("by headers: %d %q", m, v)
+ }
+ a.applyUpdateInfo(0, "9.9.9")
+ if a.autoUpdateOn() {
+ t.Fatal("still on after being told 0")
+ }
+ a.applyUpdateInfo(30, "9.9.9")
+ if m, _ := a.updateInfo(); m != 30 {
+ t.Fatalf("by the message: %d", m)
+ }
+ // Rubbish is not taken for a number.
+ h.Set(proto.AutoUpdateHeader, "soon")
+ a.applyUpdateHeaders(h)
+ if m, _ := a.updateInfo(); m != 0 {
+ t.Fatalf("rubbish: %d", m)
+ }
+}
+
+// When the interval is out the agent looks; it updates only to a newer
+// version of the server's, and tries again the next time if it did not work.
+func TestAutoUpdateTick(t *testing.T) {
+ a := New(Config{})
+ u := &updates{}
+ now := time.Now()
+ a.applyUpdateInfo(60, "9.9.9")
+
+ if !a.autoUpdateTick(now, u.do) || u.n != 1 {
+ t.Fatalf("a newer server: %d updates", u.n)
+ }
+ if a.autoUpdateTick(now.Add(30*time.Minute), u.do) || u.n != 1 {
+ t.Fatalf("before the interval is out: %d updates", u.n)
+ }
+ if !a.autoUpdateTick(now.Add(61*time.Minute), u.do) || u.n != 2 {
+ t.Fatalf("at the next time: %d updates", u.n)
+ }
+
+ // The same, an older one, a build that is not a version: nothing.
+ for _, v := range []string{Version, "0.0.1", "dev", ""} {
+ b := New(Config{})
+ b.applyUpdateInfo(60, v)
+ if b.autoUpdateTick(now, u.do) {
+ t.Fatalf("updated to %q", v)
+ }
+ }
+ // Off: nothing, whatever the server has.
+ c := New(Config{})
+ c.applyUpdateInfo(0, "9.9.9")
+ if c.autoUpdateTick(now, u.do) || u.n != 2 {
+ t.Fatalf("off, yet updated: %d", u.n)
+ }
+}
diff --git a/internal/agent/client.go b/internal/agent/client.go
index 6a9a5ad..6210998 100644
--- a/internal/agent/client.go
+++ b/internal/agent/client.go
@@ -64,6 +64,8 @@ type Agent struct {
tunMu sync.Mutex
tunAddr string
tunCancel context.CancelFunc
+
+ auto autoUpdate // what the server told of auto-update
}
// New builds an agent. It does not perform any network I/O.
@@ -115,6 +117,7 @@ func (a *Agent) Run(ctx context.Context) error {
}
}
go a.watchMain(ctx)
+ go a.autoUpdateLoop(ctx)
log.Printf("agent %s connected to %s", a.state.AgentID, a.host())
// Direct forward tunnel (low-latency TCP mux + UDP). Its address comes
@@ -321,6 +324,7 @@ func (a *Agent) pollOnce(ctx, reqCtx context.Context) error {
a.applyTunnelHeader(ctx, resp.Header)
a.learnAddrs(resp.Header.Get(proto.MainHeader), resp.Header.Get(proto.FallbackHeader))
a.applyTransport(resp.Header.Get(proto.TransportHeader))
+ a.applyUpdateHeaders(resp.Header)
a.dispatch(ctx, msg)
return nil
}
@@ -356,6 +360,7 @@ func (a *Agent) fetchConfig(ctx context.Context) bool {
defer resp.Body.Close()
a.applyTransport(resp.Header.Get(proto.TransportHeader))
a.learnAddrs(resp.Header.Get(proto.MainHeader), resp.Header.Get(proto.FallbackHeader))
+ a.applyUpdateHeaders(resp.Header)
return a.applyTunnelHeader(ctx, resp.Header)
}
diff --git a/internal/agent/wslink.go b/internal/agent/wslink.go
index 5949c79..2edd2d4 100644
--- a/internal/agent/wslink.go
+++ b/internal/agent/wslink.go
@@ -167,6 +167,7 @@ func (a *Agent) runWS(ctx context.Context) error {
a.applyTunnelAddr(ctx, m.Tunnel)
a.learnAddrs(m.Main, m.Fallback)
a.applyTransport(m.Transport)
+ a.applyUpdateInfo(m.AutoUpdate, m.ServerVersion)
if a.mode() == proto.TransportLP {
return errors.New("set to long polling")
}
diff --git a/internal/proto/proto.go b/internal/proto/proto.go
index b8f113c..d8aca1b 100644
--- a/internal/proto/proto.go
+++ b/internal/proto/proto.go
@@ -103,8 +103,19 @@ type WSMsg struct {
Transport string `json:"transport,omitempty"`
Fallback string `json:"fallback,omitempty"`
Main string `json:"main,omitempty"`
+ // Auto-update of agents: how often (minutes) an agent compares its
+ // version with the server's and updates itself if the server's is newer;
+ // 0 or missing - it does not. ServerVersion is the server's.
+ AutoUpdate int `json:"autoupdate,omitempty"`
+ ServerVersion string `json:"server_version,omitempty"`
}
+// Headers with the same on the poll answer and on /api/agent/config.
+const (
+ AutoUpdateHeader = "X-Auto-Update" // minutes, "0" or missing: off
+ ServerVersionHeader = "X-Server-Version" // the server's release version
+)
+
// Terminal / stream frame types on the session "in" channel (server -> agent).
// Reused for both PTY terminals and raw TCP port-forward streams.
const (
diff --git a/internal/proto/version.go b/internal/proto/version.go
new file mode 100644
index 0000000..a23ffd0
--- /dev/null
+++ b/internal/proto/version.go
@@ -0,0 +1,50 @@
+package proto
+
+import (
+ "strconv"
+ "strings"
+)
+
+// versionParts reads "0.2.5" (a leading v and a "-suffix" are allowed) as
+// numbers; ok is false for anything that is not a dotted number.
+func versionParts(v string) (parts []int, ok bool) {
+ v = strings.TrimPrefix(strings.TrimSpace(v), "v")
+ if i := strings.IndexAny(v, "-+"); i >= 0 {
+ v = v[:i]
+ }
+ if v == "" {
+ return nil, false
+ }
+ for _, p := range strings.Split(v, ".") {
+ n, err := strconv.Atoi(p)
+ if err != nil || n < 0 {
+ return nil, false
+ }
+ parts = append(parts, n)
+ }
+ return parts, true
+}
+
+// VersionNewer reports whether version a is higher than b. Anything that is
+// not a dotted number (a build named "dev", an empty string) is never newer
+// and never older - so a program is never "updated" down, or from a guess.
+func VersionNewer(a, b string) bool {
+ pa, okA := versionParts(a)
+ pb, okB := versionParts(b)
+ if !okA || !okB {
+ return false
+ }
+ for i := 0; i < len(pa) || i < len(pb); i++ {
+ var x, y int
+ if i < len(pa) {
+ x = pa[i]
+ }
+ if i < len(pb) {
+ y = pb[i]
+ }
+ if x != y {
+ return x > y
+ }
+ }
+ return false
+}
diff --git a/internal/proto/version_test.go b/internal/proto/version_test.go
new file mode 100644
index 0000000..8d0169f
--- /dev/null
+++ b/internal/proto/version_test.go
@@ -0,0 +1,30 @@
+package proto
+
+import "testing"
+
+// A version is newer only when it is a higher dotted number: never for the
+// same, an older one, or what is not a version at all.
+func TestVersionNewer(t *testing.T) {
+ for _, c := range []struct {
+ a, b string
+ want bool
+ }{
+ {"0.2.5", "0.2.4", true},
+ {"0.2.10", "0.2.9", true},
+ {"0.3.0", "0.2.99", true},
+ {"1.0", "0.9.9", true},
+ {"0.2.4", "0.2.4", false},
+ {"0.2.3", "0.2.4", false},
+ {"0.2", "0.2.0", false},
+ {"v0.2.5", "0.2.4", true},
+ {"", "0.2.4", false},
+ {"dev", "0.2.4", false},
+ {"0.2.5", "dev", false},
+ {"0.2.5", "", false},
+ {"0.2.5-rc1", "0.2.4", true},
+ } {
+ if got := VersionNewer(c.a, c.b); got != c.want {
+ t.Errorf("VersionNewer(%q, %q) = %v, want %v", c.a, c.b, got, c.want)
+ }
+ }
+}
diff --git a/internal/server/autoupdate.go b/internal/server/autoupdate.go
new file mode 100644
index 0000000..bbec580
--- /dev/null
+++ b/internal/server/autoupdate.go
@@ -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})
+}
diff --git a/internal/server/autoupdate_test.go b/internal/server/autoupdate_test.go
new file mode 100644
index 0000000..dcaf54b
--- /dev/null
+++ b/internal/server/autoupdate_test.go
@@ -0,0 +1,272 @@
+package server
+
+import (
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/x-grk/longpolling-remote-access/internal/proto"
+)
+
+// fakeGitLab is the place releases come from: the latest release's tag and
+// the install script on main.
+type fakeGitLab struct {
+ srv *httptest.Server
+ mu sync.Mutex
+ tag string
+ script string
+ hits int
+}
+
+func newFakeGitLab(t *testing.T, tag string) *fakeGitLab {
+ g := &fakeGitLab{tag: tag, script: "#!/bin/sh\n# LPRA server: install or update in one command.\necho updating\n"}
+ g.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ g.mu.Lock()
+ defer g.mu.Unlock()
+ g.hits++
+ switch r.URL.EscapedPath() {
+ case "/api/v4/projects/itzredtea%2Flpra/releases/permalink/latest":
+ // The real one sends the caller on to the release itself.
+ http.Redirect(w, r, "/api/v4/projects/1/releases/"+g.tag, http.StatusFound)
+ case "/api/v4/projects/1/releases/" + g.tag:
+ fmt.Fprintf(w, `{"tag_name":%q}`, g.tag)
+ case "/itzredtea/lpra/-/raw/main/server-install.sh":
+ fmt.Fprint(w, g.script)
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ t.Cleanup(g.srv.Close)
+ return g
+}
+
+func useFakeGitLab(s *Server, g *fakeGitLab) {
+ _ = s.db.SetConfig(cfgUpdateBase, g.srv.URL)
+}
+
+// A runner that only notes what it was given, in place of a real update.
+type runs struct {
+ mu sync.Mutex
+ scripts []string
+ err error
+}
+
+func (r *runs) run(script []byte) error {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ r.scripts = append(r.scripts, string(script))
+ return r.err
+}
+
+func (r *runs) n() int { r.mu.Lock(); defer r.mu.Unlock(); return len(r.scripts) }
+
+// postAuto sends the form of Настройки › Автообновление.
+func postAuto(t *testing.T, s *Server, c *http.Cookie, f url.Values) {
+ t.Helper()
+ f.Set("csrf", "tok")
+ req := httptest.NewRequest(http.MethodPost, "/settings/autoupdate", strings.NewReader(f.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.AddCookie(c)
+ req.AddCookie(&http.Cookie{Name: csrfCookie, Value: "tok"})
+ w := httptest.NewRecorder()
+ s.Handler().ServeHTTP(w, req)
+ if w.Code != http.StatusSeeOther {
+ t.Fatalf("save: %d %s", w.Code, w.Body)
+ }
+}
+
+// The settings are kept in minutes whatever unit they were given in, and
+// off unless asked for.
+func TestAutoUpdateSettings(t *testing.T) {
+ s, c := newTestServer(t)
+ if on, _ := s.agentAuto(); on {
+ t.Fatal("agents update by themselves with nothing set")
+ }
+ postAuto(t, s, c, url.Values{"agent_auto": {"1"}, "agent_every": {"2"}, "agent_unit": {"h"}, "server_auto": {"1"}, "server_every": {"1"}, "server_unit": {"d"}})
+ if on, m := s.agentAuto(); !on || m != 120 {
+ t.Fatalf("agents: %v %d", on, m)
+ }
+ if on, m := s.serverAuto(); !on || m != 1440 {
+ t.Fatalf("server: %v %d", on, m)
+ }
+ // Out of range or not a number: the default, or the largest.
+ postAuto(t, s, c, url.Values{"agent_auto": {"1"}, "agent_every": {"x"}, "agent_unit": {"min"}, "server_every": {"999"}, "server_unit": {"d"}})
+ if _, m := s.agentAuto(); m != defaultAutoMin {
+ t.Fatalf("a bad number: %d", m)
+ }
+ if on, m := s.serverAuto(); on || m != maxAutoMin {
+ t.Fatalf("server off, a large number: %v %d", on, m)
+ }
+ body := get(t, s, c, "/settings?frag=1").Body.String()
+ if !strings.Contains(body, `id="autoupdate"`) || !strings.Contains(body, "Автообновление клиентов") || !strings.Contains(body, "Автообновление сервера") {
+ t.Fatal("the settings page has no auto-update card")
+ }
+}
+
+// Agents are told of it - on the config they fetch, and with their poll -
+// together with the server's own version.
+func TestAgentsToldOfAutoUpdate(t *testing.T) {
+ s, c := newTestServer(t)
+ if err := s.db.CreateAgent(Agent{ID: "a2", Name: "n", Hostname: "h", OS: "linux", Arch: "amd64", TokenHash: hashToken("tok2")}); err != nil {
+ t.Fatal(err)
+ }
+ cfg := func() http.Header {
+ req := httptest.NewRequest(http.MethodGet, "/api/agent/config", nil)
+ req.Header.Set("X-Agent-Id", "a2")
+ req.Header.Set("Authorization", "Bearer tok2")
+ w := httptest.NewRecorder()
+ s.Handler().ServeHTTP(w, req)
+ if w.Code != http.StatusNoContent {
+ t.Fatalf("config: %d", w.Code)
+ }
+ return w.Header()
+ }
+ h := cfg()
+ if h.Get(proto.AutoUpdateHeader) != "0" || h.Get(proto.ServerVersionHeader) != Version {
+ t.Fatalf("off: %v", h)
+ }
+ postAuto(t, s, c, url.Values{"agent_auto": {"1"}, "agent_every": {"90"}, "agent_unit": {"min"}})
+ if h := cfg(); h.Get(proto.AutoUpdateHeader) != "90" {
+ t.Fatalf("on: %v", h)
+ }
+}
+
+// The latest release is read from GitLab, through its redirect.
+func TestLatestRelease(t *testing.T) {
+ s, _ := newTestServer(t)
+ g := newFakeGitLab(t, "9.9.9")
+ useFakeGitLab(s, g)
+ tag, err := s.latestRelease(t.Context())
+ if err != nil || tag != "9.9.9" {
+ t.Fatalf("latest: %q %v", tag, err)
+ }
+ g.srv.Close()
+ if _, err := s.latestRelease(t.Context()); err == nil {
+ t.Fatal("no error with GitLab out of reach")
+ }
+}
+
+// With auto-update on the server looks at GitLab by itself, at the interval,
+// and only looks: updating is the administrator's to say yes to.
+func TestServerAutoUpdateTick(t *testing.T) {
+ s, c := newTestServer(t)
+ g := newFakeGitLab(t, "99.0.0")
+ useFakeGitLab(s, g)
+ r := &runs{}
+ s.updRun = r.run
+ now := time.Now()
+
+ s.autoUpdateTick(now)
+ if g.hits != 0 {
+ t.Fatalf("off, yet it looked: %d hits", g.hits)
+ }
+ postAuto(t, s, c, url.Values{"server_auto": {"1"}, "server_every": {"60"}, "server_unit": {"min"}})
+ s.autoUpdateTick(now)
+ if st := s.updateStatus(); st.Latest != "99.0.0" || !st.Newer {
+ t.Fatalf("it did not learn of the release: %+v", st)
+ }
+ hits := g.hits
+ s.autoUpdateTick(now.Add(10 * time.Minute))
+ if g.hits != hits {
+ t.Fatal("it looked again before the interval was out")
+ }
+ s.autoUpdateTick(now.Add(61 * time.Minute))
+ if g.hits == hits {
+ t.Fatal("it did not look when the interval was out")
+ }
+ if r.n() != 0 {
+ t.Fatalf("it updated by itself: %d runs", r.n())
+ }
+ // GitLab out of reach: told on the page, kept until it answers.
+ g.srv.Close()
+ s.autoUpdateTick(now.Add(130 * time.Minute))
+ if st := s.updateStatus(); st.Error == "" {
+ t.Fatalf("the failure is not kept: %+v", st)
+ }
+}
+
+// What an administrator is offered at login: a newer release, with
+// auto-update on, not one they said no to - and again when a newer one comes.
+func TestUpdateOffer(t *testing.T) {
+ s, c := newTestServer(t)
+ g := newFakeGitLab(t, "99.0.0")
+ useFakeGitLab(s, g)
+ offer := func() string { return apiCall(t, s, c, "GET", "/api/panel/update/offer", "") }
+ if got := offer(); !strings.Contains(got, `"offer":false`) {
+ t.Fatalf("auto-update off: %s", got)
+ }
+ postAuto(t, s, c, url.Values{"server_auto": {"1"}, "server_every": {"60"}, "server_unit": {"min"}})
+ got := offer()
+ if !strings.Contains(got, `"offer":true`) || !strings.Contains(got, `"latest":"99.0.0"`) || !strings.Contains(got, `"current":"`+Version+`"`) {
+ t.Fatalf("a newer release: %s", got)
+ }
+ apiCall(t, s, c, "POST", "/api/panel/update/decline", `{"version":"99.0.0"}`)
+ if got := offer(); !strings.Contains(got, `"offer":false`) {
+ t.Fatalf("after saying no: %s", got)
+ }
+ g.tag = "99.1.0"
+ s.upd.mu.Lock()
+ s.upd.checked = time.Time{}
+ s.upd.mu.Unlock()
+ if got := offer(); !strings.Contains(got, `"offer":true`) || !strings.Contains(got, `"latest":"99.1.0"`) {
+ t.Fatalf("a still newer release: %s", got)
+ }
+ g.tag = Version
+ s.upd.mu.Lock()
+ s.upd.checked = time.Time{}
+ s.upd.mu.Unlock()
+ if got := offer(); !strings.Contains(got, `"offer":false`) {
+ t.Fatalf("nothing newer: %s", got)
+ }
+}
+
+// Anything but the install script is not run: a page of GitLab's, say.
+func TestUpdateScriptChecked(t *testing.T) {
+ s, _ := newTestServer(t)
+ g := newFakeGitLab(t, "99.0.0")
+ g.script = "502 Bad Gateway"
+ useFakeGitLab(s, g)
+ r := &runs{}
+ s.updRun = r.run
+ if err := s.startServerUpdate(t.Context()); err == nil || r.n() != 0 {
+ t.Fatalf("a page was run as a script: %v, %d runs", err, r.n())
+ }
+}
+
+// From the panel: check now, and update now - only to a newer release.
+func TestUpdatePanelButtons(t *testing.T) {
+ s, c := newTestServer(t)
+ g := newFakeGitLab(t, "99.0.0")
+ useFakeGitLab(s, g)
+ r := &runs{}
+ s.updRun = r.run
+ got := apiCall(t, s, c, "POST", "/api/panel/update/check", "")
+ if !strings.Contains(got, `"newer":true`) || !strings.Contains(got, `"latest":"99.0.0"`) || !strings.Contains(got, `"current":"`+Version+`"`) {
+ t.Fatalf("check: %s", got)
+ }
+ if got := apiCall(t, s, c, "POST", "/api/panel/update/run", ""); !strings.Contains(got, `"ok":true`) || r.n() != 1 {
+ t.Fatalf("run: %s, %d runs", got, r.n())
+ }
+ g.tag = Version
+ if got := apiCall(t, s, c, "POST", "/api/panel/update/run", ""); !strings.Contains(got, `"ok":false`) || r.n() != 1 {
+ t.Fatalf("run with nothing newer: %s, %d runs", got, r.n())
+ }
+}
+
+// Not for everybody: only an administrator updates the server.
+func TestUpdateOnlyForAdmins(t *testing.T) {
+ s, c := newTestServer(t)
+ a, _ := s.db.AdminByUsername("adm")
+ if a == nil {
+ t.Fatal("no admin")
+ }
+ // The test's user is an administrator; the guard is the route's own.
+ if got := apiCall(t, s, c, "POST", "/api/panel/update/check", ""); strings.Contains(got, "только для администраторов") {
+ t.Fatalf("an administrator was refused: %s", got)
+ }
+}
diff --git a/internal/server/handlers_agent.go b/internal/server/handlers_agent.go
index 37a7a69..a3f69db 100644
--- a/internal/server/handlers_agent.go
+++ b/internal/server/handlers_agent.go
@@ -56,6 +56,7 @@ func (s *Server) handlePoll(w http.ResponseWriter, r *http.Request) {
msg := s.hub.WaitControl(r.Context().Done(), a.ID, pollHold)
// Set after the hold so a setting changed meanwhile reaches the agent now.
s.setTunnelHeader(w)
+ s.setUpdateHeaders(w)
w.Header().Set(proto.TransportHeader, s.transportOf(a.ID))
writeJSON(w, msg)
}
@@ -83,6 +84,7 @@ func (s *Server) handleAgentConfig(w http.ResponseWriter, r *http.Request) {
return
}
s.setTunnelHeader(w)
+ s.setUpdateHeaders(w)
w.Header().Set(proto.TransportHeader, s.transportOf(a.ID))
w.WriteHeader(http.StatusNoContent)
}
diff --git a/internal/server/handlers_panel.go b/internal/server/handlers_panel.go
index bbb5bdf..169b123 100644
--- a/internal/server/handlers_panel.go
+++ b/internal/server/handlers_panel.go
@@ -446,6 +446,7 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
"AIModels": s.aiModels(),
"Search": s.searchConf(),
"ImageKey": func() string { _, k := s.imageConf(); return k }(),
+ "Auto": s.autoView(),
"DBNow": dbNow,
"DBChosen": dbChosen,
"UfwInstalled": ufwInstalled,
diff --git a/internal/server/server.go b/internal/server/server.go
index d03ac01..ccf84cd 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -65,6 +65,9 @@ type Server struct {
wsAgents wsAgents // agents on their WebSocket now
pinMisses pinMisses // wrong PINs per session
assets map[string]string // static file -> hash of its content
+
+ upd updState // the server's own updating
+ updRun func(script []byte) error // runs the install script; the real one unless a test sets it
}
// New builds the server, parsing embedded templates.
@@ -269,6 +272,11 @@ func (s *Server) Handler() http.Handler {
mux.Handle("POST /api/panel/ai/image", s.requireAdmin(s.handleAIImage))
mux.Handle("GET /api/panel/ai/image/{job}", s.requireAdmin(s.handleAIImageJob))
mux.Handle("POST /settings/ai", s.adminsOnly(s.handleSetAI))
+ mux.Handle("POST /settings/autoupdate", s.adminsOnly(s.handleSetAutoUpdate))
+ mux.Handle("POST /api/panel/update/check", s.adminsOnly(s.handleUpdateCheck))
+ mux.Handle("GET /api/panel/update/offer", s.adminsOnly(s.handleUpdateOffer))
+ mux.Handle("POST /api/panel/update/decline", s.adminsOnly(s.handleUpdateDecline))
+ mux.Handle("POST /api/panel/update/run", s.adminsOnly(s.handleUpdateRun))
mux.Handle("POST /settings/db", s.adminsOnly(s.handleSetDB))
mux.Handle("POST /settings/db/restart", s.adminsOnly(s.handleRestart))
mux.Handle("POST /api/panel/account/keys/begin", s.requireAdmin(s.handleKeyRegisterBegin))
diff --git a/internal/server/web/static/ai.js b/internal/server/web/static/ai.js
index 5f165de..e3ac1a3 100644
--- a/internal/server/web/static/ai.js
+++ b/internal/server/web/static/ai.js
@@ -721,12 +721,15 @@
// the next question (or by note, when done by hand).
function showResult(text) {
const el = add('result', '');
- el.innerHTML = 'Результат
';
- el.querySelector('pre').textContent = text;
// A picture made: shown here, from its marker (kept with the talk, so
- // it comes back with it) - and what the next edit works on.
+ // it comes back with it) - and what the next edit works on. Its words
+ // are the model's alone: the user sees the picture.
const m = /\[картинка: (https?:\/\/[^\]\s]+)\]/.exec(text);
- if (!m) return;
+ if (!m) {
+ el.innerHTML = 'Результат
';
+ el.querySelector('pre').textContent = text;
+ return;
+ }
lastPic = m[1];
const follow = nearBottom();
const link = document.createElement('a'), img = document.createElement('img');
@@ -736,7 +739,7 @@
img.onerror = () => { link.classList.add('gone'); link.textContent = 'Картинка больше не открывается'; };
link.appendChild(img);
el.classList.add('pic');
- el.insertBefore(link, el.firstChild);
+ el.appendChild(link);
}
// makeImage: a picture asked for in an answer - put at the image service
// through the server, asked about until it is done. What comes back is
diff --git a/internal/server/web/static/app.css b/internal/server/web/static/app.css
index a2747d9..f0a4d26 100644
--- a/internal/server/web/static/app.css
+++ b/internal/server/web/static/app.css
@@ -963,7 +963,7 @@ dialog.uidlg { width:fit-content; min-width:min(300px, 92vw); max-width:min(560p
.ai-pic { display:block; overflow:hidden; border:1px solid var(--border); border-radius:12px; background:#0c0f13; line-height:0; }
.ai-pic img { display:block; width:100%; height:auto; max-height:440px; object-fit:contain; background:#0c0f13; }
.ai-pic.gone { padding:18px 12px; line-height:1.4; text-align:center; color:var(--muted); font-size:12.5px; }
-.ai-msg.result.pic details { padding:0 4px; }
+
.ai-msg.result pre { margin:6px 0 2px; max-height:220px; overflow:auto; white-space:pre-wrap; font-size:12px; }
.ai-cap { font-size:12px; margin-bottom:4px; }
/* Modes as pills: on - lit in the accent, off - a quiet outline. */
@@ -1156,6 +1156,13 @@ body.no-motion *, body.no-motion *::before, body.no-motion *::after, body.no-mot
.ai-model-sub { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:var(--muted); font-size:12.5px; }
.ai-model-sub:not(:empty)::before { content:'· '; }
.ai-model-body { display:grid; gap:10px; min-width:0; padding:0 12px 12px; }
+.ai-form label.check { display:inline-flex; align-items:center; gap:8px; margin:0; color:var(--text); font-size:14px; cursor:pointer; }
+.ai-form label.check input { margin:0; }
+.auto-every { display:flex; align-items:center; gap:8px; }
+.auto-every input[type=number] { width:84px; flex:none; }
+.auto-every select { width:auto; flex:none; }
+.auto-status { font-size:13px; line-height:1.5; }
+.auto-status .bad { color:var(--danger); }
.ai-model-head { display:flex; align-items:flex-end; gap:8px; margin:2px 0 4px; }
.ai-model-head .ai-model-name { flex:1; min-width:0; margin:0; }
.ai-model-head .ai-model-short { flex:none; width:96px; margin:0; }
diff --git a/internal/server/web/static/pages.js b/internal/server/web/static/pages.js
index 65584a3..c83da86 100644
--- a/internal/server/web/static/pages.js
+++ b/internal/server/web/static/pages.js
@@ -23,6 +23,30 @@
}, e => alert('Не скопировалось: ' + e.message));
});
if (window.UsersTable) window.UsersTable.mount(root);
+ // Автообновление: look at GitLab now, and update now.
+ const au = root.querySelector('#autoupdate .auto-check');
+ if (au) {
+ const st = root.querySelector('#autoupdate .auto-status'), run = root.querySelector('#autoupdate .auto-run');
+ const post = u => fetch(u, {method: 'POST', credentials: 'same-origin'}).then(r => r.json()).catch(() => ({ok: false, error: 'нет связи с сервером'}));
+ const say = (text, bad) => { st.textContent = ''; const s = document.createElement('span'); s.textContent = text; if (bad) s.className = 'bad'; st.appendChild(s); };
+ au.onclick = async () => {
+ au.disabled = true; say('Проверяю…');
+ const r = await post('/api/panel/update/check');
+ au.disabled = false;
+ if (!r.ok) { say(r.error, true); return; }
+ say('Версия сервера: ' + r.current + ' · на GitLab: ' + r.latest + (r.newer ? ' - вышла новая' : ' - это последняя'));
+ run.hidden = !r.newer;
+ };
+ run.onclick = async () => {
+ if (!await UI.confirm('Обновить сервер сейчас? Панель будет недоступна около минуты.', {ok: 'Обновить'})) return;
+ run.disabled = true; say('Запускаю обновление…');
+ const r = await post('/api/panel/update/run');
+ run.disabled = false;
+ if (!r.ok) { say(r.error, true); return; }
+ run.hidden = true;
+ say('Обновление до ' + r.latest + ' запущено: сервер перезапустится в течение минуты - потом обновите страницу.');
+ };
+ }
// ИИ-ассистент: a card per model, added from the blank one and taken
// away by its «Удалить» (the last one stays).
const list = root.querySelector('.ai-models'), blank = root.querySelector('template.ai-model-new');
diff --git a/internal/server/web/static/ui.js b/internal/server/web/static/ui.js
index fa3f4bf..f887d7d 100644
--- a/internal/server/web/static/ui.js
+++ b/internal/server/web/static/ui.js
@@ -50,6 +50,7 @@
input.value = o.value || '';
yes.textContent = o.ok || 'OK';
yes.className = 'ui-yes' + (o.danger ? ' btn danger' : '');
+ dlg.querySelector('.ui-no').textContent = o.cancel || 'Отмена';
// «Отмена» red, unless «да» is the red one (a delete): never two reds.
dlg.querySelector('.ui-no').classList.toggle('close', !o.danger);
return new Promise(done => {
diff --git a/internal/server/web/static/updatenote.js b/internal/server/web/static/updatenote.js
new file mode 100644
index 0000000..8a06f72
--- /dev/null
+++ b/internal/server/web/static/updatenote.js
@@ -0,0 +1,47 @@
+// A new release of the server: an administrator is offered it when they come
+// in - after the PIN, or at once when there is none - once for each tab. The
+// window says the versions and has «Обновить» and «Отказаться»; a refusal is
+// kept for that release (a still newer one asks again). Only when the server
+// is set to look for releases (Настройки › Автообновление).
+(function () {
+ 'use strict';
+ var root = document.documentElement;
+ var SHOWN = 'lpra.upd.shown';
+
+ // The PIN lock hides the page until it is unlocked.
+ function whenUnlocked(fn) {
+ if (!root.classList.contains('pin-wait')) { fn(); return; }
+ var mo = new MutationObserver(function () {
+ if (!root.classList.contains('pin-wait')) { mo.disconnect(); fn(); }
+ });
+ mo.observe(root, {attributes: true, attributeFilter: ['class']});
+ }
+ function toast(text, bad) {
+ if (window.FileViews && FileViews.toast) FileViews.toast(text, bad);
+ }
+ function post(url, body) {
+ return fetch(url, {method: 'POST', headers: {'Content-Type': 'application/json'}, credentials: 'same-origin',
+ body: JSON.stringify(body || {})}).then(function (r) { return r.json(); })
+ .catch(function () { return {ok: false, error: 'нет связи с сервером'}; });
+ }
+
+ function offer() {
+ try { if (sessionStorage.getItem(SHOWN) === '1') return; } catch (e) { /* offered again */ }
+ fetch('/api/panel/update/offer', {cache: 'no-store', credentials: 'same-origin'})
+ .then(function (r) { return r.ok ? r.json() : {}; })
+ .then(function (d) {
+ if (!d.offer || !window.UI) return;
+ try { sessionStorage.setItem(SHOWN, '1'); } catch (e) { /* not remembered */ }
+ UI.confirm('Вышла новая версия LPRA {chip}\nОбновить сервер сейчас? Панель будет недоступна около минуты.',
+ {ok: 'Обновить', cancel: 'Отказаться', chip: d.current + ' → ' + d.latest}).then(function (yes) {
+ if (!yes) { post('/api/panel/update/decline', {version: d.latest}); return; }
+ post('/api/panel/update/run').then(function (r) {
+ if (r.ok) toast('Обновление до ' + r.latest + ' запущено: сервер перезапустится в течение минуты - потом обновите страницу');
+ else toast(r.error || 'обновить не получилось', true);
+ });
+ });
+ })
+ .catch(function () { /* no offer */ });
+ }
+ whenUnlocked(offer);
+})();
diff --git a/internal/server/web/templates/app.html b/internal/server/web/templates/app.html
index 5b6b038..f832ff3 100644
--- a/internal/server/web/templates/app.html
+++ b/internal/server/web/templates/app.html
@@ -213,6 +213,7 @@
+