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

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

No files matched your search

+7
View File
@@ -67,6 +67,13 @@
не расшифровать. не расшифровать.
- **Проброс портов** - `http` через панель, `tcp`/`udp` через прямой туннель. - **Проброс портов** - `http` через панель, `tcp`/`udp` через прямой туннель.
- **Статус** - нагрузка, память, диски, сеть, тест скорости канала до сервера. - **Статус** - нагрузка, память, диски, сеть, тест скорости канала до сервера.
- **Автообновление** («Настройки › Автообновление», обе части выключены, пока не включены):
*клиентов* - агенты раз в N минут/часов/суток сверяют свою версию с версией сервера и, если у сервера новее,
обновляются сами (как по кнопке «Обновить»; нужен root или sudo без пароля; агентам до 0.2.5 один раз обновиться
придётся вручную); *сервера* - сервер раз в N сверяет себя с последним релизом на GitLab, и когда вышел новый,
администратору при входе (после PIN) показывается окно «Обновить / Отказаться» - отказ запоминается для этой
версии. «Обновить» запускает `server-install.sh` отдельной службой systemd (нужны root и systemd); там же кнопки
«Проверить сейчас» и «Обновить сейчас».
- **Вход** - пароль и/или ключи доступа (passkeys); пароль можно выключить, когда есть ключ. - **Вход** - пароль и/или ключи доступа (passkeys); пароль можно выключить, когда есть ключ.
- **PIN-код** (необязательный, 4 цифры) - спрашивается в каждой новой вкладке браузера, но не при - **PIN-код** (необязательный, 4 цифры) - спрашивается в каждой новой вкладке браузера, но не при
перезагрузке. На устройстве можно не спрашивать его 5/15/30/60 минут после ввода. перезагрузке. На устройстве можно не спрашивать его 5/15/30/60 минут после ввода.
+4
View File
@@ -6,6 +6,7 @@
package main package main
import ( import (
"context"
"flag" "flag"
"log" "log"
"net/http" "net/http"
@@ -42,6 +43,9 @@ func main() {
log.Fatalf("init server: %v", err) log.Fatalf("init server: %v", err)
} }
// The server updates itself, if told to (Настройки › Автообновление).
go srv.RunAutoUpdate(context.Background())
httpSrv := &http.Server{ httpSrv := &http.Server{
Addr: *addr, Addr: *addr,
Handler: srv.Handler(), Handler: srv.Handler(),
+95
View File
@@ -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)
}
}
}
+78
View File
@@ -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)
}
}
+5
View File
@@ -64,6 +64,8 @@ type Agent struct {
tunMu sync.Mutex tunMu sync.Mutex
tunAddr string tunAddr string
tunCancel context.CancelFunc tunCancel context.CancelFunc
auto autoUpdate // what the server told of auto-update
} }
// New builds an agent. It does not perform any network I/O. // 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.watchMain(ctx)
go a.autoUpdateLoop(ctx)
log.Printf("agent %s connected to %s", a.state.AgentID, a.host()) log.Printf("agent %s connected to %s", a.state.AgentID, a.host())
// Direct forward tunnel (low-latency TCP mux + UDP). Its address comes // 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.applyTunnelHeader(ctx, resp.Header)
a.learnAddrs(resp.Header.Get(proto.MainHeader), resp.Header.Get(proto.FallbackHeader)) a.learnAddrs(resp.Header.Get(proto.MainHeader), resp.Header.Get(proto.FallbackHeader))
a.applyTransport(resp.Header.Get(proto.TransportHeader)) a.applyTransport(resp.Header.Get(proto.TransportHeader))
a.applyUpdateHeaders(resp.Header)
a.dispatch(ctx, msg) a.dispatch(ctx, msg)
return nil return nil
} }
@@ -356,6 +360,7 @@ func (a *Agent) fetchConfig(ctx context.Context) bool {
defer resp.Body.Close() defer resp.Body.Close()
a.applyTransport(resp.Header.Get(proto.TransportHeader)) a.applyTransport(resp.Header.Get(proto.TransportHeader))
a.learnAddrs(resp.Header.Get(proto.MainHeader), resp.Header.Get(proto.FallbackHeader)) a.learnAddrs(resp.Header.Get(proto.MainHeader), resp.Header.Get(proto.FallbackHeader))
a.applyUpdateHeaders(resp.Header)
return a.applyTunnelHeader(ctx, resp.Header) return a.applyTunnelHeader(ctx, resp.Header)
} }
+1
View File
@@ -167,6 +167,7 @@ func (a *Agent) runWS(ctx context.Context) error {
a.applyTunnelAddr(ctx, m.Tunnel) a.applyTunnelAddr(ctx, m.Tunnel)
a.learnAddrs(m.Main, m.Fallback) a.learnAddrs(m.Main, m.Fallback)
a.applyTransport(m.Transport) a.applyTransport(m.Transport)
a.applyUpdateInfo(m.AutoUpdate, m.ServerVersion)
if a.mode() == proto.TransportLP { if a.mode() == proto.TransportLP {
return errors.New("set to long polling") return errors.New("set to long polling")
} }
+11
View File
@@ -103,8 +103,19 @@ type WSMsg struct {
Transport string `json:"transport,omitempty"` Transport string `json:"transport,omitempty"`
Fallback string `json:"fallback,omitempty"` Fallback string `json:"fallback,omitempty"`
Main string `json:"main,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). // Terminal / stream frame types on the session "in" channel (server -> agent).
// Reused for both PTY terminals and raw TCP port-forward streams. // Reused for both PTY terminals and raw TCP port-forward streams.
const ( const (
+50
View File
@@ -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
}
+30
View File
@@ -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)
}
}
}
+389
View File
@@ -0,0 +1,389 @@
package server
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"github.com/x-grk/longpolling-remote-access/internal/proto"
)
// Auto-update, two kinds, each off until asked for (Настройки › Автообновление):
// - of the agents: they are told how often to compare their version with
// the server's and, if the server's is newer, to take the agent from it
// (as the panel's «Обновить» does);
// - of the server: it compares itself with the latest release on GitLab;
// when that is newer, an administrator is offered it at login (after the
// PIN), and on «Обновить» the install script is run (the same one as
// `wget -qO- …/server-install.sh | sudo sh`). Nothing runs unasked.
const (
cfgAgentAuto = "agent_autoupdate"
cfgAgentAutoMin = "agent_autoupdate_min"
cfgServerAuto = "server_autoupdate"
cfgServerAutoMin = "server_autoupdate_min"
// Where releases are. Not in the settings: for tests.
cfgUpdateRepo = "update_repo"
cfgUpdateBase = "update_base"
DefaultUpdateRepo = "itzredtea/lpra"
DefaultUpdateBase = "https://gitlab.com"
defaultAutoMin = 360 // 6 hours
maxAutoMin = 60 * 24 * 30 // 30 days
)
// autoSetting reads an "on" flag and its interval in minutes.
func (s *Server) autoSetting(onKey, minKey string) (bool, int) {
on, _ := s.db.GetConfig(onKey)
m, _ := strconv.Atoi(func() string { v, _ := s.db.GetConfig(minKey); return v }())
if m < 1 || m > maxAutoMin {
m = defaultAutoMin
}
return on == "1", m
}
func (s *Server) agentAuto() (bool, int) { return s.autoSetting(cfgAgentAuto, cfgAgentAutoMin) }
func (s *Server) serverAuto() (bool, int) { return s.autoSetting(cfgServerAuto, cfgServerAutoMin) }
// setUpdateHeaders tells an agent the server's version and how often to
// compare ("0": never).
func (s *Server) setUpdateHeaders(w http.ResponseWriter) {
w.Header().Set(proto.ServerVersionHeader, Version)
w.Header().Set(proto.AutoUpdateHeader, strconv.Itoa(s.agentAutoMinutes()))
}
// agentAutoMinutes is the interval agents are given, 0 when off.
func (s *Server) agentAutoMinutes() int {
if on, m := s.agentAuto(); on {
return m
}
return 0
}
// minutesOf turns "2" and "h" into 120: min, h, d.
func minutesOf(n, unit string) int {
v, err := strconv.Atoi(strings.TrimSpace(n))
if err != nil || v < 1 {
return defaultAutoMin
}
switch unit {
case "h":
v *= 60
case "d":
v *= 60 * 24
}
if v > maxAutoMin {
v = maxAutoMin
}
return v
}
// POST /settings/autoupdate - both settings.
func (s *Server) handleSetAutoUpdate(w http.ResponseWriter, r *http.Request) {
if !checkCSRF(r) {
http.Error(w, "bad csrf", http.StatusForbidden)
return
}
flag := func(v string) string {
if v == "1" {
return "1"
}
return ""
}
_ = s.db.SetConfig(cfgAgentAuto, flag(r.FormValue("agent_auto")))
_ = s.db.SetConfig(cfgAgentAutoMin, strconv.Itoa(minutesOf(r.FormValue("agent_every"), r.FormValue("agent_unit"))))
_ = s.db.SetConfig(cfgServerAuto, flag(r.FormValue("server_auto")))
_ = s.db.SetConfig(cfgServerAutoMin, strconv.Itoa(minutesOf(r.FormValue("server_every"), r.FormValue("server_unit"))))
// A server that was told to look again looks at once.
s.upd.mu.Lock()
s.upd.last = time.Time{}
s.upd.mu.Unlock()
http.Redirect(w, r, "/settings#autoupdate", http.StatusSeeOther)
}
// intervalParts is a number of minutes as the form shows it: 120 - 2 h.
func intervalParts(min int) (int, string) {
switch {
case min%(60*24) == 0:
return min / (60 * 24), "d"
case min%60 == 0:
return min / 60, "h"
}
return min, "min"
}
// autoView is what Настройки show of the settings.
type autoView struct {
AgentOn, ServerOn bool
AgentEvery, ServerEvery int
AgentUnit, ServerUnit string
Status updStatus
}
func (s *Server) autoView() autoView {
var v autoView
var am, sm int
v.AgentOn, am = s.agentAuto()
v.ServerOn, sm = s.serverAuto()
v.AgentEvery, v.AgentUnit = intervalParts(am)
v.ServerEvery, v.ServerUnit = intervalParts(sm)
v.Status = s.updateStatus()
return v
}
// updState is what the updating of the server remembers.
type updState struct {
mu sync.Mutex
last time.Time // when it last looked
latest string
checked time.Time
err string
}
// updStatus is that, for the settings page.
type updStatus struct {
Current, Latest string
Checked string
Error string
Newer bool
}
func (s *Server) updateStatus() updStatus {
s.upd.mu.Lock()
defer s.upd.mu.Unlock()
st := updStatus{Current: Version, Latest: s.upd.latest, Error: s.upd.err, Newer: proto.VersionNewer(s.upd.latest, Version)}
if !s.upd.checked.IsZero() {
st.Checked = s.upd.checked.Format("02.01.2006 15:04")
}
return st
}
func (s *Server) updateRepoBase() (base, repo string) {
base, _ = s.db.GetConfig(cfgUpdateBase)
if base == "" {
base = DefaultUpdateBase
}
repo, _ = s.db.GetConfig(cfgUpdateRepo)
if repo == "" {
repo = DefaultUpdateRepo
}
return strings.TrimRight(base, "/"), repo
}
var updateClient = &http.Client{Timeout: 30 * time.Second}
// latestRelease is the tag of the project's latest release on GitLab.
func (s *Server) latestRelease(ctx context.Context) (string, error) {
base, repo := s.updateRepoBase()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, base+"/api/v4/projects/"+url.PathEscape(repo)+"/releases/permalink/latest", nil)
req.Header.Set("Accept", "application/json")
resp, err := updateClient.Do(req)
if err != nil {
return "", errors.New("GitLab не отвечает: " + err.Error())
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
return "", errors.New("GitLab ответил " + resp.Status)
}
var out struct {
Tag string `json:"tag_name"`
}
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&out); err != nil || out.Tag == "" {
return "", errors.New("не прочиталась версия последнего релиза")
}
return strings.TrimPrefix(out.Tag, "v"), nil
}
// checkRelease looks at GitLab and keeps what it found for the page.
func (s *Server) checkRelease(ctx context.Context) (string, error) {
tag, err := s.latestRelease(ctx)
s.upd.mu.Lock()
defer s.upd.mu.Unlock()
s.upd.checked = time.Now()
if err != nil {
s.upd.err = err.Error()
return "", err
}
s.upd.latest, s.upd.err = tag, ""
return tag, nil
}
// installScript is the install script on the project's main branch.
func (s *Server) installScript(ctx context.Context) ([]byte, error) {
base, repo := s.updateRepoBase()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, base+"/"+repo+"/-/raw/main/server-install.sh", nil)
resp, err := updateClient.Do(req)
if err != nil {
return nil, errors.New("скрипт установки не скачался: " + err.Error())
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
return nil, errors.New("скрипт установки: GitLab ответил " + resp.Status)
}
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
// What is run as root must be the script, not a page of an error.
if !strings.HasPrefix(string(b), "#!/bin/sh\n") || !strings.Contains(string(b), "LPRA server") {
return nil, errors.New("по адресу скрипта установки лежит не он")
}
return b, nil
}
// startServerUpdate takes the install script and has it run, apart from
// this process: it stops the service, and a script in the service's own
// control group would go with it.
func (s *Server) startServerUpdate(ctx context.Context) error {
script, err := s.installScript(ctx)
if err != nil {
return err
}
run := s.updRun
if run == nil {
run = runServerUpdate
}
return run(script)
}
// runServerUpdate runs the script as a transient systemd unit of its own,
// in the folder this server lives in. Needs root and systemd.
func runServerUpdate(script []byte) error {
if os.Geteuid() != 0 {
return errors.New("сервер запущен не от root - обновить его самому нельзя, запустите server-install.sh")
}
sr, err := exec.LookPath("systemd-run")
if err != nil {
return errors.New("нет systemd-run - автообновление сервера работает только под systemd")
}
exe, err := os.Executable()
if err != nil {
return err
}
dir := filepath.Dir(exe)
path := filepath.Join(dir, ".server-update.sh")
if err := os.WriteFile(path, script, 0o700); err != nil {
return errors.New("скрипт не записался: " + err.Error())
}
out, err := exec.Command(sr, "--unit=lpra-self-update", "--collect", "--setenv=LPRA_DIR="+dir,
"/bin/sh", "-c", "sh '"+path+"'; rm -f '"+path+"'").CombinedOutput()
if err != nil {
_ = os.Remove(path)
return fmt.Errorf("systemd-run: %s", strings.TrimSpace(string(out)))
}
return nil
}
// autoUpdateTick: one look at the clock. When the server is to look at
// GitLab (on, and the interval is out) it does, and keeps what it found.
func (s *Server) autoUpdateTick(now time.Time) {
on, min := s.serverAuto()
if !on {
return
}
s.upd.mu.Lock()
if !s.upd.last.IsZero() && now.Sub(s.upd.last) < time.Duration(min)*time.Minute {
s.upd.mu.Unlock()
return
}
s.upd.last = now
s.upd.mu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
_, _ = s.checkRelease(ctx)
}
// RunAutoUpdate watches the clock until ctx ends.
func (s *Server) RunAutoUpdate(ctx context.Context) {
t := time.NewTicker(30 * time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case now := <-t.C:
s.autoUpdateTick(now)
}
}
}
// POST /api/panel/update/check - looks at GitLab now.
func (s *Server) handleUpdateCheck(w http.ResponseWriter, r *http.Request) {
tag, err := s.checkRelease(r.Context())
if err != nil {
writeJSON(w, map[string]any{"ok": false, "error": err.Error(), "current": Version})
return
}
writeJSON(w, map[string]any{"ok": true, "current": Version, "latest": tag, "newer": proto.VersionNewer(tag, Version)})
}
// POST /api/panel/update/run - updates the server now, to a newer release.
func (s *Server) handleUpdateRun(w http.ResponseWriter, r *http.Request) {
tag, err := s.checkRelease(r.Context())
if err != nil {
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
return
}
if !proto.VersionNewer(tag, Version) {
writeJSON(w, map[string]any{"ok": false, "error": "уже последняя версия (" + Version + ")"})
return
}
if err := s.startServerUpdate(r.Context()); err != nil {
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
return
}
writeJSON(w, map[string]any{"ok": true, "latest": tag})
}
func declinedKey(id int64) string { return "update_declined_" + strconv.FormatInt(id, 10) }
// GET /api/panel/update/offer - whether this administrator is to be offered
// an update now: auto-update on, a release newer than this one, and not one
// they declined.
func (s *Server) handleUpdateOffer(w http.ResponseWriter, r *http.Request) {
no := func() { writeJSON(w, map[string]any{"offer": false}) }
on, min := s.serverAuto()
if !on {
no()
return
}
// Not looked at lately (just started, or the interval is out): look now.
s.upd.mu.Lock()
stale := s.upd.checked.IsZero() || time.Since(s.upd.checked) >= time.Duration(min)*time.Minute
s.upd.mu.Unlock()
if stale {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
_, _ = s.checkRelease(ctx)
cancel()
}
st := s.updateStatus()
declined, _ := s.db.GetConfig(declinedKey(s.currentAdmin(r).ID))
if !st.Newer || declined == st.Latest {
no()
return
}
writeJSON(w, map[string]any{"offer": true, "current": st.Current, "latest": st.Latest})
}
// POST /api/panel/update/decline - {version}: this administrator is not to
// be asked about that release again.
func (s *Server) handleUpdateDecline(w http.ResponseWriter, r *http.Request) {
var in struct {
Version string `json:"version"`
}
if json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<10)).Decode(&in) == nil && len(in.Version) <= 32 {
_ = s.db.SetConfig(declinedKey(s.currentAdmin(r).ID), in.Version)
}
writeJSON(w, map[string]any{"ok": true})
}
+272
View File
@@ -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 = "<html>502 Bad Gateway</html>"
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)
}
}
+2
View File
@@ -56,6 +56,7 @@ func (s *Server) handlePoll(w http.ResponseWriter, r *http.Request) {
msg := s.hub.WaitControl(r.Context().Done(), a.ID, pollHold) msg := s.hub.WaitControl(r.Context().Done(), a.ID, pollHold)
// Set after the hold so a setting changed meanwhile reaches the agent now. // Set after the hold so a setting changed meanwhile reaches the agent now.
s.setTunnelHeader(w) s.setTunnelHeader(w)
s.setUpdateHeaders(w)
w.Header().Set(proto.TransportHeader, s.transportOf(a.ID)) w.Header().Set(proto.TransportHeader, s.transportOf(a.ID))
writeJSON(w, msg) writeJSON(w, msg)
} }
@@ -83,6 +84,7 @@ func (s *Server) handleAgentConfig(w http.ResponseWriter, r *http.Request) {
return return
} }
s.setTunnelHeader(w) s.setTunnelHeader(w)
s.setUpdateHeaders(w)
w.Header().Set(proto.TransportHeader, s.transportOf(a.ID)) w.Header().Set(proto.TransportHeader, s.transportOf(a.ID))
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
+1
View File
@@ -446,6 +446,7 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
"AIModels": s.aiModels(), "AIModels": s.aiModels(),
"Search": s.searchConf(), "Search": s.searchConf(),
"ImageKey": func() string { _, k := s.imageConf(); return k }(), "ImageKey": func() string { _, k := s.imageConf(); return k }(),
"Auto": s.autoView(),
"DBNow": dbNow, "DBNow": dbNow,
"DBChosen": dbChosen, "DBChosen": dbChosen,
"UfwInstalled": ufwInstalled, "UfwInstalled": ufwInstalled,
+8
View File
@@ -65,6 +65,9 @@ type Server struct {
wsAgents wsAgents // agents on their WebSocket now wsAgents wsAgents // agents on their WebSocket now
pinMisses pinMisses // wrong PINs per session pinMisses pinMisses // wrong PINs per session
assets map[string]string // static file -> hash of its content 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. // 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("POST /api/panel/ai/image", s.requireAdmin(s.handleAIImage))
mux.Handle("GET /api/panel/ai/image/{job}", s.requireAdmin(s.handleAIImageJob)) 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/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", s.adminsOnly(s.handleSetDB))
mux.Handle("POST /settings/db/restart", s.adminsOnly(s.handleRestart)) mux.Handle("POST /settings/db/restart", s.adminsOnly(s.handleRestart))
mux.Handle("POST /api/panel/account/keys/begin", s.requireAdmin(s.handleKeyRegisterBegin)) mux.Handle("POST /api/panel/account/keys/begin", s.requireAdmin(s.handleKeyRegisterBegin))
+8 -5
View File
@@ -721,12 +721,15 @@
// the next question (or by note, when done by hand). // the next question (or by note, when done by hand).
function showResult(text) { function showResult(text) {
const el = add('result', ''); const el = add('result', '');
el.innerHTML = '<details><summary>Результат</summary><pre></pre></details>';
el.querySelector('pre').textContent = text;
// A picture made: shown here, from its marker (kept with the talk, so // 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); const m = /\[картинка: (https?:\/\/[^\]\s]+)\]/.exec(text);
if (!m) return; if (!m) {
el.innerHTML = '<details><summary>Результат</summary><pre></pre></details>';
el.querySelector('pre').textContent = text;
return;
}
lastPic = m[1]; lastPic = m[1];
const follow = nearBottom(); const follow = nearBottom();
const link = document.createElement('a'), img = document.createElement('img'); const link = document.createElement('a'), img = document.createElement('img');
@@ -736,7 +739,7 @@
img.onerror = () => { link.classList.add('gone'); link.textContent = 'Картинка больше не открывается'; }; img.onerror = () => { link.classList.add('gone'); link.textContent = 'Картинка больше не открывается'; };
link.appendChild(img); link.appendChild(img);
el.classList.add('pic'); 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 // 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 // through the server, asked about until it is done. What comes back is
+8 -1
View File
@@ -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 { 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 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-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-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; } .ai-cap { font-size:12px; margin-bottom:4px; }
/* Modes as pills: on - lit in the accent, off - a quiet outline. */ /* 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 { 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-sub:not(:empty)::before { content:'· '; }
.ai-model-body { display:grid; gap:10px; min-width:0; padding:0 12px 12px; } .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 { 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-name { flex:1; min-width:0; margin:0; }
.ai-model-head .ai-model-short { flex:none; width:96px; margin:0; } .ai-model-head .ai-model-short { flex:none; width:96px; margin:0; }
+24
View File
@@ -23,6 +23,30 @@
}, e => alert('Не скопировалось: ' + e.message)); }, e => alert('Не скопировалось: ' + e.message));
}); });
if (window.UsersTable) window.UsersTable.mount(root); 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 // ИИ-ассистент: a card per model, added from the blank one and taken
// away by its «Удалить» (the last one stays). // away by its «Удалить» (the last one stays).
const list = root.querySelector('.ai-models'), blank = root.querySelector('template.ai-model-new'); const list = root.querySelector('.ai-models'), blank = root.querySelector('template.ai-model-new');
+1
View File
@@ -50,6 +50,7 @@
input.value = o.value || ''; input.value = o.value || '';
yes.textContent = o.ok || 'OK'; yes.textContent = o.ok || 'OK';
yes.className = 'ui-yes' + (o.danger ? ' btn danger' : ''); 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. // «Отмена» red, unless «да» is the red one (a delete): never two reds.
dlg.querySelector('.ui-no').classList.toggle('close', !o.danger); dlg.querySelector('.ui-no').classList.toggle('close', !o.danger);
return new Promise(done => { return new Promise(done => {
+47
View File
@@ -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);
})();
+1
View File
@@ -213,6 +213,7 @@
<script src="{{asset "account.js"}}"></script> <script src="{{asset "account.js"}}"></script>
<script src="{{asset "machine.js"}}"></script> <script src="{{asset "machine.js"}}"></script>
<script src="{{asset "ai.js"}}"></script> <script src="{{asset "ai.js"}}"></script>
<script src="{{asset "updatenote.js"}}"></script>
<script>App.start();</script> <script>App.start();</script>
</body> </body>
</html>{{end}} </html>{{end}}
@@ -212,6 +212,36 @@
</form> </form>
</div> </div>
<div class="card" id="autoupdate">
<h2>Автообновление</h2>
<form method="post" action="/settings/autoupdate" class="ai-form">
<input type="hidden" name="csrf" value="{{.CSRF}}">
{{/* The same spoilers as the assistant's. */}}
<details class="ai-search"><summary class="ai-model-sum"><span class="ai-model-title">Автообновление клиентов</span><span class="ai-model-sub">{{if .Auto.AgentOn}}каждые {{.Auto.AgentEvery}} {{if eq .Auto.AgentUnit "d"}}сут.{{else if eq .Auto.AgentUnit "h"}}ч.{{else}}мин.{{end}}{{else}}выключено{{end}}</span></summary>
<div class="ai-model-body">
<p class="muted" style="margin:0">Клиенты раз в указанное время сверяют свою версию с версией сервера и, если у сервера новее, обновляются сами - так же, как по кнопке «Обновить». Нужны права root или sudo без пароля; агентам старше 0.2.5 один раз обновиться придётся вручную.</p>
<label class="check"><input type="checkbox" name="agent_auto" value="1"{{if .Auto.AgentOn}} checked{{end}}> Включить автообновление клиентов</label>
<div class="auto-every"><span class="muted">Проверять раз в</span>
<input type="number" name="agent_every" min="1" max="999" value="{{.Auto.AgentEvery}}">
<select name="agent_unit"><option value="min"{{if eq .Auto.AgentUnit "min"}} selected{{end}}>минут</option><option value="h"{{if eq .Auto.AgentUnit "h"}} selected{{end}}>часов</option><option value="d"{{if eq .Auto.AgentUnit "d"}} selected{{end}}>суток</option></select></div>
</div></details>
<details class="ai-search"><summary class="ai-model-sum"><span class="ai-model-title">Автообновление сервера</span><span class="ai-model-sub">{{if .Auto.ServerOn}}каждые {{.Auto.ServerEvery}} {{if eq .Auto.ServerUnit "d"}}сут.{{else if eq .Auto.ServerUnit "h"}}ч.{{else}}мин.{{end}}{{else}}выключено{{end}} · сейчас {{.Auto.Status.Current}}</span></summary>
<div class="ai-model-body">
<p class="muted" style="margin:0">Сервер раз в указанное время сверяет свою версию с последним релизом на GitLab. Если вышел новый, администратору при входе (после PIN) показывается окно: версия и кнопки «Обновить» и «Отказаться». «Обновить» запускает тот же скрипт, что и вручную: <code>wget -qO- https://gitlab.com/itzredtea/lpra/-/raw/main/server-install.sh | sudo sh</code> - от root, поэтому нужны root и systemd; панель при этом недоступна около минуты.</p>
<label class="check"><input type="checkbox" name="server_auto" value="1"{{if .Auto.ServerOn}} checked{{end}}> Предлагать обновление сервера при входе</label>
<div class="auto-every"><span class="muted">Проверять раз в</span>
<input type="number" name="server_every" min="1" max="999" value="{{.Auto.ServerEvery}}">
<select name="server_unit"><option value="min"{{if eq .Auto.ServerUnit "min"}} selected{{end}}>минут</option><option value="h"{{if eq .Auto.ServerUnit "h"}} selected{{end}}>часов</option><option value="d"{{if eq .Auto.ServerUnit "d"}} selected{{end}}>суток</option></select></div>
<div class="auto-status muted" aria-live="polite">Версия сервера: <b>{{.Auto.Status.Current}}</b>{{if .Auto.Status.Latest}} · на GitLab: <b>{{.Auto.Status.Latest}}</b>{{end}}{{if .Auto.Status.Checked}} · проверено {{.Auto.Status.Checked}}{{end}}{{if .Auto.Status.Error}}<br><span class="bad">{{.Auto.Status.Error}}</span>{{end}}</div>
<div class="row">
<button type="button" class="btn ghost small auto-check">Проверить сейчас</button>
<button type="button" class="btn small auto-run"{{if not .Auto.Status.Newer}} hidden{{end}}>Обновить сейчас</button>
</div>
</div></details>
<div><button type="submit">Сохранить</button></div>
</form>
</div>
<div class="card"> <div class="card">
<h2>Enroll-секрет</h2> <h2>Enroll-секрет</h2>
<p class="muted">Общий секрет для регистрации новых агентов.</p> <p class="muted">Общий секрет для регистрации новых агентов.</p>
+2 -1
View File
@@ -98,7 +98,8 @@ func (s *Server) handleAgentWS(w http.ResponseWriter, r *http.Request) {
return wsjson.Write(wctx, conn, m) return wsjson.Write(wctx, conn, m)
} }
cfg := func() proto.WSMsg { 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 { if send(cfg()) != nil {
return return