From 3d2f886f047220fbe2531c20ec678fb115618f1c Mon Sep 17 00:00:00 2001 From: iRedTea Date: Sun, 18 Oct 2026 12:33:24 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=9D=D0=B0=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=B9=D0=BA=D0=B8=20=E2=80=BA=20=D0=90=D0=B2=D1=82=D0=BE=D0=BE?= =?UTF-8?q?=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20-=20cl?= =?UTF-8?q?ients=20compare=20their=20version=20with=20the=20server's=20eve?= =?UTF-8?q?ry=20N=20and=20update=20themselves;=20the=20server=20looks=20at?= =?UTF-8?q?=20GitLab=20every=20N=20and,=20when=20a=20newer=20release=20is?= =?UTF-8?q?=20out,=20offers=20it=20to=20an=20administrator=20at=20login=20?= =?UTF-8?q?(after=20the=20PIN)=20with=20=C2=AB=D0=9E=D0=B1=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=D0=B8=D1=82=D1=8C=C2=BB=20and=20=C2=AB=D0=9E=D1=82=D0=BA?= =?UTF-8?q?=D0=B0=D0=B7=D0=B0=D1=82=D1=8C=D1=81=D1=8F=C2=BB;=20the=20words?= =?UTF-8?q?=20of=20a=20made=20picture's=20result=20are=20the=20model's=20o?= =?UTF-8?q?nly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 7 + cmd/server/main.go | 4 + internal/agent/autoupdate.go | 95 +++++ internal/agent/autoupdate_test.go | 78 ++++ internal/agent/client.go | 5 + internal/agent/wslink.go | 1 + internal/proto/proto.go | 11 + internal/proto/version.go | 50 +++ internal/proto/version_test.go | 30 ++ internal/server/autoupdate.go | 389 ++++++++++++++++++++ internal/server/autoupdate_test.go | 272 ++++++++++++++ internal/server/handlers_agent.go | 2 + internal/server/handlers_panel.go | 1 + internal/server/server.go | 8 + internal/server/web/static/ai.js | 13 +- internal/server/web/static/app.css | 9 +- internal/server/web/static/pages.js | 24 ++ internal/server/web/static/ui.js | 1 + internal/server/web/static/updatenote.js | 47 +++ internal/server/web/templates/app.html | 1 + internal/server/web/templates/settings.html | 30 ++ internal/server/wsagent.go | 3 +- 22 files changed, 1074 insertions(+), 7 deletions(-) create mode 100644 internal/agent/autoupdate.go create mode 100644 internal/agent/autoupdate_test.go create mode 100644 internal/proto/version.go create mode 100644 internal/proto/version_test.go create mode 100644 internal/server/autoupdate.go create mode 100644 internal/server/autoupdate_test.go create mode 100644 internal/server/web/static/updatenote.js 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 @@ + {{end}} diff --git a/internal/server/web/templates/settings.html b/internal/server/web/templates/settings.html index ed93594..2f15704 100644 --- a/internal/server/web/templates/settings.html +++ b/internal/server/web/templates/settings.html @@ -212,6 +212,36 @@ +
+

Автообновление

+
+ + {{/* The same spoilers as the assistant's. */}} + + +
+
+
+

Enroll-секрет

Общий секрет для регистрации новых агентов.

diff --git a/internal/server/wsagent.go b/internal/server/wsagent.go index 0914aaf..7713047 100644 --- a/internal/server/wsagent.go +++ b/internal/server/wsagent.go @@ -98,7 +98,8 @@ func (s *Server) handleAgentWS(w http.ResponseWriter, r *http.Request) { return wsjson.Write(wctx, conn, m) } cfg := func() proto.WSMsg { - return proto.WSMsg{T: "cfg", Tunnel: s.tunnelAdvert(), Transport: s.transportOf(a.ID), Fallback: s.db.DirectAddress(), Main: s.db.HostAddress()} + return proto.WSMsg{T: "cfg", Tunnel: s.tunnelAdvert(), Transport: s.transportOf(a.ID), Fallback: s.db.DirectAddress(), Main: s.db.HostAddress(), + AutoUpdate: s.agentAutoMinutes(), ServerVersion: Version} } if send(cfg()) != nil { return