Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
adadfb19a6 | ||
|
|
3f288c6fd6 | ||
|
|
3d2f886f04 |
No files matched your search
@@ -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 минут после ввода.
|
||||||
@@ -227,3 +234,5 @@ curl -fsSL https://panel.example/install.sh | sudo HOST=https://panel.example EN
|
|||||||
<img width="1255" height="526" alt="image" src="docs/screenshots/3.png" />
|
<img width="1255" height="526" alt="image" src="docs/screenshots/3.png" />
|
||||||
|
|
||||||
<img width="1260" height="654" alt="image" src="docs/screenshots/4.png" />
|
<img width="1260" height="654" alt="image" src="docs/screenshots/4.png" />
|
||||||
|
|
||||||
|
*Автор иконки репозитория - * [grafixpoint](https://www.flaticon.com/authors/grafixpoint)
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
0.2.4
|
0.2.5
|
||||||
@@ -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(),
|
||||||
|
|||||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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', '');
|
||||||
|
// 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. Its words
|
||||||
|
// are the model's alone: the user sees the picture.
|
||||||
|
const m = /\[картинка: (https?:\/\/[^\]\s]+)\]/.exec(text);
|
||||||
|
if (!m) {
|
||||||
el.innerHTML = '<details><summary>Результат</summary><pre></pre></details>';
|
el.innerHTML = '<details><summary>Результат</summary><pre></pre></details>';
|
||||||
el.querySelector('pre').textContent = text;
|
el.querySelector('pre').textContent = text;
|
||||||
// A picture made: shown here, from its marker (kept with the talk, so
|
return;
|
||||||
// it comes back with it) - and what the next edit works on.
|
}
|
||||||
const m = /\[картинка: (https?:\/\/[^\]\s]+)\]/.exec(text);
|
|
||||||
if (!m) 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
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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 => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
})();
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user