24 Commits
Author SHA1 Message Date
redtea cb2673d941 Revert "feat: DEL in place of PGDN among the terminal's extra keys"
This reverts commit cf855fdc23.
2026-10-14 14:59:18 +03:00
redtea cf855fdc23 feat: DEL in place of PGDN among the terminal's extra keys 2026-10-14 14:58:06 +03:00
redtea d456ffe757 feat: phone layout for the panel, and Termux-style extra keys in the terminal 2026-10-14 14:43:06 +03:00
redtea 81a2c96e0b fix: centre the ufw switch under the create button 2026-10-13 23:23:03 +03:00
redtea e0dc15740d feat: the ufw switch of a new forward rule goes under the button 2026-10-13 23:22:07 +03:00
redtea fbd8f30de3 feat: the new forward rule form on one line, with short type names 2026-10-13 23:18:49 +03:00
redtea 67c02ad6b4 chore: keep the version at 0.0.6 2026-10-13 23:13:15 +03:00
redtea 5155bc36cd feat: measure each direction of the speed test for 7 s (0.0.7) 2026-10-13 23:12:30 +03:00
redtea ab8751d436 feat: live reading during the speed test, and a 15 s limit that keeps what was measured 2026-10-13 23:06:56 +03:00
redtea 6441b4329d fix: don't let a slow browser link time out the relayed speed test 2026-10-13 22:49:19 +03:00
redtea 189050b2b6 fix: end the browser speed test feed without a 410 or 502 2026-10-13 22:33:42 +03:00
redtea fd75f324b8 fix: keep the speed results hidden until there is a measurement 2026-10-13 21:02:39 +03:00
redteaandClaude Opus 5.5 789e1da93b feat: open a port in ufw by hand from the panel settings
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:42:09 +03:00
redteaandClaude Opus 5.5 87921a1381 feat: speed test through the browser, client - panel - browser (0.0.6)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:31:41 +03:00
redteaandClaude Opus 5.5 b51363bf72 feat: optional label on a forward rule
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:31:41 +03:00
redteaandClaude Opus 5.5 201d63ee1e feat: open the file manager in the user's home directory
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:31:41 +03:00
redtea 5e5718b397 chore: call the machine edit link "Изменить" in page headers 2026-10-13 19:06:49 +03:00
redtea c675bb0ec7 feat: status and settings links in the header of every machine page 2026-10-13 19:06:11 +03:00
redtea a02fdc01b0 feat: show the name of an icon button on hover 2026-10-13 19:04:57 +03:00
redtea 8ba2dd1f1b chore: name the actions column, drop hardcoded tunnel port and 2 KB mentions
The tunnel port default lives only in tunnel.DefaultPort; docs and
comments refer to "the tunnel port". The file manager hint no longer
states an edit limit that was not the real one.
2026-10-13 19:04:22 +03:00
redtea 171e14176a fix: small fixes 2026-10-13 19:00:50 +03:00
redtea 86cb0226ab feat: machine status page, speed test, live presence, forwards without ufw (0.0.5)
Status page per machine: CPU load, RAM and disk of the client, sampled
by the agent only while the page is open and visible (one reading
serves every open tab), plus a button that has the agent measure its
link to the host: ping, down and up.

Online/offline now comes from the control polls the hub holds open, so
a dropped agent shows offline at once instead of 45 s later, and the
dashboard keeps the status column live.

A forward rule can be created "without ufw": the panel opens nothing in
ufw for it and so revokes nothing when it is deleted. The checkbox is
shown only while the ufw integration is on.

Dashboard row actions are icons; a status action is added.
2026-10-13 18:56:15 +03:00
redtea 7f4d645f19 fix: keep the machine row buttons on one line 2026-10-13 16:57:54 +03:00
redtea f3cb7701f2 fix: remove an agent's forward rules with the agent (0.0.4)
Deleting a machine left its forward rules behind: the host ports stayed
bound and "busy" with no page to remove them from. The rules, their
listeners and ufw rules now go with the agent, and rules orphaned by
older versions are dropped on start.

A host port equal to the direct tunnel port gets its own message.
2026-10-13 16:30:53 +03:00
42 changed files with 2032 additions and 167 deletions

No files matched your search

+14 -14
View File
@@ -1,4 +1,4 @@
# LPRA — LongPolling Remote Access # LPRA - LongPolling Remote Access
Лёгкий self-hosted сервис для доступа к Linux-машинам **без белого IP** (за NAT). Лёгкий self-hosted сервис для доступа к Linux-машинам **без белого IP** (за NAT).
Агент на клиенте сам исходяще подключается к серверу по HTTP long-polling; админ Агент на клиенте сам исходяще подключается к серверу по HTTP long-polling; админ
@@ -11,9 +11,9 @@
``` ```
## Компоненты ## Компоненты
- **Сервер** (`cmd/server`) — админ-панель + точка встречи для агентов. За nginx (TLS). - **Сервер** (`cmd/server`) - админ-панель + точка встречи для агентов. За nginx (TLS).
- **Агент** (`cmd/agent`) — Linux-бинарь, systemd-сервис от root, PTY по запросу. - **Агент** (`cmd/agent`) - Linux-бинарь, systemd-сервис от root, PTY по запросу.
- **install.sh** — интерактивный установщик, отдаётся сервером как статика. - **install.sh** - интерактивный установщик, отдаётся сервером как статика.
## Сборка ## Сборка
```sh ```sh
@@ -28,7 +28,7 @@
Поставьте перед ним nginx по `deploy/nginx.conf.example` (TLS, long-poll/WS). Поставьте перед ним nginx по `deploy/nginx.conf.example` (TLS, long-poll/WS).
Локально без TLS можно добавить `-insecure-cookies`. Локально без TLS можно добавить `-insecure-cookies`.
Первый запуск: откройте `/setup` — мастер создаст администратора. Дальше `/settings`: Первый запуск: откройте `/setup` - мастер создаст администратора. Дальше `/settings`:
адрес хоста (по умолчанию `https://localhost:8445`), enroll-секрет и готовая адрес хоста (по умолчанию `https://localhost:8445`), enroll-секрет и готовая
команда установки. команда установки.
@@ -37,36 +37,36 @@
```sh ```sh
curl -fsSL https://localhost:8445/install.sh | sudo HOST=https://localhost:8445 ENROLL_SECRET=<секрет> sh curl -fsSL https://localhost:8445/install.sh | sudo HOST=https://localhost:8445 ENROLL_SECRET=<секрет> sh
``` ```
Или интерактивно (спросит секрет, а если агент уже стоит — предложит удалить): Или интерактивно (спросит секрет, а если агент уже стоит - предложит удалить):
```sh ```sh
curl -fsSL https://localhost:8445/install.sh | sudo sh curl -fsSL https://localhost:8445/install.sh | sudo sh
``` ```
## Проброс портов и прямой туннель ## Проброс портов и прямой туннель
Правила проброса создаются на странице машины. Режим `http` идёт через адрес панели (long-poll), Правила проброса создаются на странице машины. Режим `http` идёт через адрес панели (long-poll),
режимы `tcp`/`udp` — через прямой туннель: агент держит исходящее соединение к порту туннеля хоста режимы `tcp`/`udp` - через прямой туннель: агент держит исходящее соединение к порту туннеля хоста
(по умолчанию `8446`, флаг `-tunnel` / `LPRA_TUNNEL_ADDR`). (флаг `-tunnel` / `LPRA_TUNNEL_ADDR`).
Порт туннеля и хост, к которому для него подключаются агенты (например, IP вместо домена панели), Порт туннеля и хост, к которому для него подключаются агенты (например, IP вместо домена панели),
меняются в `/settings` → «Прямой туннель». Агенты забирают адрес с панели сами — при подключении и меняются в `/settings` → «Прямой туннель». Агенты забирают адрес с панели сами - при подключении и
при каждом опросе, переустановка не нужна. Локальные `TUNNEL_ADDR` / `TUNNEL_PORT` в окружении агента при каждом опросе, переустановка не нужна. Локальные `TUNNEL_ADDR` / `TUNNEL_PORT` в окружении агента
имеют приоритет над настройкой панели (`TUNNEL_ADDR=off` отключает туннель). имеют приоритет над настройкой панели (`TUNNEL_ADDR=off` отключает туннель).
Если на хосте активен ufw и сервер запущен от root, панель сама открывает порт хоста правила Если на хосте активен ufw и сервер запущен от root, панель сама открывает порт хоста правила
(tcp для `http`/`tcp`, udp для `udp`) и порт туннеля для прямых режимов, а при удалении правила — закрывает. (tcp для `http`/`tcp`, udp для `udp`) и порт туннеля для прямых режимов, а при удалении правила - закрывает.
При `ProtectSystem=strict` в systemd-юните ufw нужны права на запись: `ReadWritePaths=/etc/ufw /run`. При `ProtectSystem=strict` в systemd-юните ufw нужны права на запись: `ReadWritePaths=/etc/ufw /run`.
## Версия ## Версия
Версия задаётся в одном месте — `VERSION.txt`; файл вшивается в сервер и агент при сборке. Версия задаётся в одном месте - `VERSION.txt`; файл вшивается в сервер и агент при сборке.
## Пользователь терминала ## Пользователь терминала
По умолчанию терминал открывается от пользователя, под которым запущен агент (на Linux — root). По умолчанию терминал открывается от пользователя, под которым запущен агент (на Linux - root).
В окне «Изменить машину» можно выбрать другого пользователя машины или создать нового В окне «Изменить машину» можно выбрать другого пользователя машины или создать нового
(имя, пароль, опционально — добавить в sudo). Смена и создание работают, только если агент запущен от root. (имя, пароль, опционально - добавить в sudo). Смена и создание работают, только если агент запущен от root.
Файловый менеджер по-прежнему работает от пользователя агента. Файловый менеджер по-прежнему работает от пользователя агента.
## Безопасность ## Безопасность
- Пароли админов — bcrypt; сессии в HttpOnly/Secure/SameSite куках; CSRF на формах; rate-limit логина. - Пароли админов - bcrypt; сессии в HttpOnly/Secure/SameSite куках; CSRF на формах; rate-limit логина.
- Агенты: общий enroll-секрет → индивидуальный токен (хранится хешем, сверка constant-time). - Агенты: общий enroll-секрет → индивидуальный токен (хранится хешем, сверка constant-time).
- Открытие терминала пишется в таблицу `audit` (терминал даёт root-доступ), как и смена/создание пользователя. - Открытие терминала пишется в таблицу `audit` (терминал даёт root-доступ), как и смена/создание пользователя.
+1 -1
View File
@@ -1 +1 @@
0.0.3 0.0.6
+4 -2
View File
@@ -10,9 +10,11 @@ import (
"net/url" "net/url"
"os" "os"
"os/signal" "os/signal"
"strconv"
"syscall" "syscall"
"github.com/x-grk/longpolling-remote-access/internal/agent" "github.com/x-grk/longpolling-remote-access/internal/agent"
"github.com/x-grk/longpolling-remote-access/internal/tunnel"
) )
func main() { func main() {
@@ -48,7 +50,7 @@ func envOr(key, def string) string {
return def return def
} }
// tunnelAddr derives the direct-tunnel address (host:8446) from the server URL. // tunnelAddr derives the direct-tunnel address (host:port) from the server URL.
// It is only the starting point: the agent follows the host and port set in // It is only the starting point: the agent follows the host and port set in
// the panel settings, unless TUNNEL_ADDR (the whole address) or TUNNEL_PORT // the panel settings, unless TUNNEL_ADDR (the whole address) or TUNNEL_PORT
// (just the port) pins it here. Set TUNNEL_ADDR=off to disable the tunnel. // (just the port) pins it here. Set TUNNEL_ADDR=off to disable the tunnel.
@@ -63,5 +65,5 @@ func tunnelAddr(hostURL string) string {
if err != nil || u.Hostname() == "" { if err != nil || u.Hostname() == "" {
return "" return ""
} }
return net.JoinHostPort(u.Hostname(), envOr("TUNNEL_PORT", "8446")) return net.JoinHostPort(u.Hostname(), envOr("TUNNEL_PORT", strconv.Itoa(tunnel.DefaultPort)))
} }
+3 -1
View File
@@ -10,16 +10,18 @@ import (
"log" "log"
"net/http" "net/http"
"os" "os"
"strconv"
"time" "time"
"github.com/x-grk/longpolling-remote-access/internal/server" "github.com/x-grk/longpolling-remote-access/internal/server"
"github.com/x-grk/longpolling-remote-access/internal/tunnel"
) )
func main() { func main() {
addr := flag.String("addr", envOr("LPRA_ADDR", "127.0.0.1:8091"), "listen address (behind nginx)") addr := flag.String("addr", envOr("LPRA_ADDR", "127.0.0.1:8091"), "listen address (behind nginx)")
dbPath := flag.String("db", envOr("LPRA_DB", "lpra.db"), "path to SQLite database") dbPath := flag.String("db", envOr("LPRA_DB", "lpra.db"), "path to SQLite database")
distDir := flag.String("dist", envOr("LPRA_DIST", "dist"), "directory with agent binaries served at /download/") distDir := flag.String("dist", envOr("LPRA_DIST", "dist"), "directory with agent binaries served at /download/")
tunnelAddr := flag.String("tunnel", envOr("LPRA_TUNNEL_ADDR", ":8446"), "listen address for the direct TCP+UDP forward tunnel (empty disables)") tunnelAddr := flag.String("tunnel", envOr("LPRA_TUNNEL_ADDR", ":"+strconv.Itoa(tunnel.DefaultPort)), "listen address for the direct TCP+UDP forward tunnel (empty disables)")
insecureCookies := flag.Bool("insecure-cookies", envOr("LPRA_INSECURE_COOKIES", "") == "1", insecureCookies := flag.Bool("insecure-cookies", envOr("LPRA_INSECURE_COOKIES", "") == "1",
"do not mark cookies Secure (plain-HTTP local testing only)") "do not mark cookies Secure (plain-HTTP local testing only)")
flag.Parse() flag.Parse()
+5 -5
View File
@@ -10,15 +10,15 @@
# sudo cp deploy/lpra-server.service /etc/systemd/system/ # sudo cp deploy/lpra-server.service /etc/systemd/system/
# sudo systemctl daemon-reload && sudo systemctl enable --now lpra-server # sudo systemctl daemon-reload && sudo systemctl enable --now lpra-server
# #
# Direct port forwarding (tcp/udp modes) uses port 8446 on the host — open it in # Direct port forwarding (tcp/udp modes) uses the tunnel port on the host, which
# the firewall: ufw allow 8446/tcp ; ufw allow 8446/udp # has to be open in the firewall for tcp and udp. (The "http" forward mode and
# (The "http" forward mode and terminal/files go through nginx :8445 and need # terminal/files go through nginx :8445 and need nothing extra.) Set the tunnel
# nothing extra.) Override with LPRA_TUNNEL_ADDR, or set it empty to disable. # address with LPRA_TUNNEL_ADDR, or set it empty to disable.
# The port, and the host agents dial for it, can also be changed in the panel # The port, and the host agents dial for it, can also be changed in the panel
# settings; agents pick the new address up from the panel by themselves. # settings; agents pick the new address up from the panel by themselves.
# #
# ufw integration (the panel opening forwarded ports itself) needs root and a # ufw integration (the panel opening forwarded ports itself) needs root and a
# writable /etc/ufw and /run — see ReadWritePaths below. # writable /etc/ufw and /run - see ReadWritePaths below.
[Unit] [Unit]
Description=LPRA remote access host (admin panel + agent rendezvous) Description=LPRA remote access host (admin panel + agent rendezvous)
+3
View File
@@ -36,6 +36,9 @@ server {
# Long-poll behaviour: no buffering, generous read timeout. # Long-poll behaviour: no buffering, generous read timeout.
proxy_buffering off; proxy_buffering off;
proxy_cache off; proxy_cache off;
# Pass uploads on as they arrive: the speed test through the browser
# times them on the client.
proxy_request_buffering off;
proxy_read_timeout 120s; proxy_read_timeout 120s;
proxy_send_timeout 120s; proxy_send_timeout 120s;
} }
+1 -1
View File
@@ -5,6 +5,7 @@ go 1.26
require ( require (
github.com/creack/pty v1.1.21 github.com/creack/pty v1.1.21
golang.org/x/crypto v0.31.0 golang.org/x/crypto v0.31.0
golang.org/x/sys v0.28.0
modernc.org/sqlite v1.24.0 modernc.org/sqlite v1.24.0
) )
@@ -13,7 +14,6 @@ require (
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.16 // indirect github.com/mattn/go-isatty v0.0.16 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.28.0 // indirect
modernc.org/libc v1.22.5 // indirect modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect modernc.org/memory v1.5.0 // indirect
+1 -1
View File
@@ -33,7 +33,7 @@ type Config struct {
EnrollSecret string EnrollSecret string
Name string // optional friendly name Name string // optional friendly name
StatePath string // where the enrolled id+token are persisted StatePath string // where the enrolled id+token are persisted
TunnelAddr string // host:port for the direct forward tunnel (e.g. REDACTED:8446); empty disables TunnelAddr string // host:port for the direct forward tunnel (e.g. panel.example:port); empty disables
// TunnelPinned keeps TunnelAddr as configured locally (TUNNEL_ADDR / // TunnelPinned keeps TunnelAddr as configured locally (TUNNEL_ADDR /
// TUNNEL_PORT) instead of following the address set in the panel. // TUNNEL_PORT) instead of following the address set in the panel.
TunnelPinned bool TunnelPinned bool
+39
View File
@@ -9,7 +9,9 @@ import (
"io" "io"
"os" "os"
"os/exec" "os/exec"
"os/user"
"path/filepath" "path/filepath"
"runtime"
"sort" "sort"
"strings" "strings"
"unicode/utf8" "unicode/utf8"
@@ -79,8 +81,18 @@ func fsList(path string) (proto.FSListResult, error) {
if path == "" { if path == "" {
path = "/" path = "/"
} }
// "~" / "~name" is where the file manager starts: the home directory, or
// the root when there is none to open.
home := strings.HasPrefix(path, "~") && !strings.Contains(path, "/")
if home {
path = homeDir(path[1:])
}
abs, _ := filepath.Abs(path) abs, _ := filepath.Abs(path)
entries, err := os.ReadDir(abs) entries, err := os.ReadDir(abs)
if err != nil && home && abs != "/" {
abs = "/"
entries, err = os.ReadDir(abs)
}
if err != nil { if err != nil {
return proto.FSListResult{}, err return proto.FSListResult{}, err
} }
@@ -222,3 +234,30 @@ func fsZip(paths []string, dest string) (any, error) {
} }
return map[string]string{"dest": dest}, nil return map[string]string{"dest": dest}, nil
} }
// homeDir returns the home directory of the named user ("" = the user the
// agent runs as), or "/" when it cannot be told.
func homeDir(name string) string {
if name != "" {
if validUserName(name) {
if u, err := user.Lookup(name); err == nil && u.HomeDir != "" {
return u.HomeDir
}
}
return "/"
}
if u, err := user.Current(); err == nil && u.HomeDir != "" {
return u.HomeDir
}
if h := os.Getenv("HOME"); h != "" {
return h
}
// A root service often has neither a passwd entry we can read nor $HOME.
if os.Geteuid() == 0 {
if runtime.GOOS == "darwin" {
return "/var/root"
}
return "/root"
}
return "/"
}
+167
View File
@@ -0,0 +1,167 @@
package agent
import (
"context"
"crypto/rand"
"encoding/json"
"errors"
"io"
"net/http"
"time"
"github.com/x-grk/longpolling-remote-access/internal/proto"
)
const (
// speedWindow is how long each direction is measured for, unless the
// server asks for another window; speedWindowMax bounds what it may ask.
speedWindow = 3 * time.Second
speedWindowMax = 30 * time.Second
// speedWait is how long the download may take to start: in a relayed test
// its bytes come from the browser.
speedWait = 10 * time.Second
// speedUpCap bounds the upload, whatever the link does in the window.
speedUpCap = 48 << 20
)
// doSpeed measures the agent's HTTP link to the server: round-trip time of an
// empty request, then how much flows each way in the window. In a relayed
// test the server pipes both streams to the browser, so they run end to end.
func (a *Agent) doSpeed(ctx context.Context, env proto.TaskEnvelope) proto.TaskResult {
var args proto.SysSpeedArgs
if len(env.Args) > 0 {
_ = json.Unmarshal(env.Args, &args)
}
window := speedWindow
if args.WindowMs > 0 {
window = min(time.Duration(args.WindowMs)*time.Millisecond, speedWindowMax)
}
res, err := a.measureSpeed(ctx, args.Relay, window)
if err != nil {
return proto.TaskResult{Error: err.Error()}
}
data, _ := json.Marshal(res)
return proto.TaskResult{OK: true, Data: data}
}
func (a *Agent) measureSpeed(ctx context.Context, relay bool, window time.Duration) (proto.SysSpeedResult, error) {
res := proto.SysSpeedResult{Relay: relay}
streams := "/api/agent/speed"
if relay {
streams += "/relay"
}
// Ping: the first request may pay for a new connection, so keep the best.
for i := 0; i < 4; i++ {
start := time.Now()
resp, err := a.speedRequest(ctx, http.MethodGet, "/api/agent/speed/down?bytes=0", nil)
if err != nil {
return res, err
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
ms := float64(time.Since(start).Microseconds()) / 1000
if i == 0 || ms < res.PingMs {
res.PingMs = ms
}
}
down, err := a.speedDown(ctx, streams+"/down", window)
if err != nil {
return res, err
}
res.DownBps = down
// Up: send until the window closes or the cap is hit.
body := &speedSource{deadline: time.Now().Add(window), left: speedUpCap}
start := time.Now()
resp, err := a.speedRequest(ctx, http.MethodPost, streams+"/up", body)
if err != nil {
return res, err
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
res.UpBps = bps(int64(speedUpCap-body.left), time.Since(start))
return res, nil
}
// speedDown reads what the server streams for the window, counted from the
// first byte, and returns the rate.
func (a *Agent) speedDown(ctx context.Context, path string, window time.Duration) (float64, error) {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
t := time.AfterFunc(speedWait, cancel)
defer t.Stop()
resp, err := a.speedRequest(ctx, http.MethodGet, path, nil)
if err != nil {
return 0, err
}
defer resp.Body.Close()
var (
n int64
start time.Time
buf = make([]byte, 64<<10)
)
for {
k, err := resp.Body.Read(buf)
if k > 0 && start.IsZero() {
start = time.Now()
t.Reset(window)
}
n += int64(k)
if err != nil {
break
}
}
if start.IsZero() {
return 0, errors.New("нет данных для замера")
}
return bps(n, time.Since(start)), nil
}
func (a *Agent) speedRequest(ctx context.Context, method, path string, body io.Reader) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, method, a.cfg.Host+path, body)
if err != nil {
return nil, err
}
a.authHeaders(req)
resp, err := a.http.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
resp.Body.Close()
return nil, errStatus(resp.StatusCode)
}
return resp, nil
}
func bps(n int64, d time.Duration) float64 {
if d <= 0 {
return 0
}
return float64(n) * 8 / d.Seconds()
}
// speedSource is an upload body of incompressible bytes that ends at the
// deadline or after `left` bytes.
type speedSource struct {
deadline time.Time
left int
block []byte
}
func (s *speedSource) Read(p []byte) (int, error) {
if s.left <= 0 || !time.Now().Before(s.deadline) {
return 0, io.EOF
}
if s.block == nil {
s.block = make([]byte, 64<<10)
rand.Read(s.block)
}
n := copy(p, s.block)
if n > s.left {
n = s.left
}
s.left -= n
return n, nil
}
+45
View File
@@ -0,0 +1,45 @@
package agent
import (
"runtime"
"sync"
"syscall"
"github.com/x-grk/longpolling-remote-access/internal/proto"
)
// statMu serializes samples: the CPU figure is a delta against the last one.
var statMu sync.Mutex
// sysStat samples the machine's load. It costs nothing while nobody asks: the
// panel requests it only for an open status page.
func sysStat() (proto.SysStatResult, error) {
statMu.Lock()
defer statMu.Unlock()
res := proto.SysStatResult{NCPU: runtime.NumCPU()}
cpu, err := cpuPercent()
if err != nil {
return res, err
}
res.CPU = cpu
if res.MemTotal, res.MemUsed, err = memUsage(); err != nil {
return res, err
}
var fs syscall.Statfs_t
if err := syscall.Statfs("/", &fs); err != nil {
return res, err
}
res.DiskTotal = uint64(fs.Blocks) * uint64(fs.Bsize)
res.DiskFree = uint64(fs.Bavail) * uint64(fs.Bsize)
return res, nil
}
func clampPercent(v float64) float64 {
if v < 0 {
return 0
}
if v > 100 {
return 100
}
return v
}
+43
View File
@@ -0,0 +1,43 @@
package agent
import (
"bytes"
"os/exec"
"runtime"
"strconv"
"golang.org/x/sys/unix"
)
// cpuPercent sums the per-process CPU shares ps reports. macOS exposes the
// host CPU ticks only through Mach calls, which need cgo.
func cpuPercent() (float64, error) {
out, err := exec.Command("/bin/ps", "-A", "-o", "%cpu=").Output()
if err != nil {
return 0, err
}
var sum float64
for _, f := range bytes.Fields(out) {
// Some locales print a decimal comma.
v, _ := strconv.ParseFloat(string(bytes.Replace(f, []byte(","), []byte("."), 1)), 64)
sum += v
}
return clampPercent(sum / float64(runtime.NumCPU())), nil
}
// memUsage takes the kernel's own share of memory still available
// (kern.memorystatus_level, what `memory_pressure` prints).
func memUsage() (total, used uint64, err error) {
total, err = unix.SysctlUint64("hw.memsize")
if err != nil {
return 0, 0, err
}
level, err := unix.SysctlUint32("kern.memorystatus_level")
if err != nil {
return 0, 0, err
}
if level > 100 {
level = 100
}
return total, total / 100 * uint64(100-level), nil
}
+121
View File
@@ -0,0 +1,121 @@
package agent
import (
"bytes"
"errors"
"os"
"strconv"
"time"
)
// Previous /proc/stat reading; the load is the busy share of the ticks since.
var (
cpuPrevIdle, cpuPrevTotal uint64
cpuPrevAt time.Time
)
// cpuStaleAfter is how old the previous reading may be: past it the delta
// would average over a stretch nobody was watching, so take a fresh pair.
const cpuStaleAfter = 15 * time.Second
func cpuPercent() (float64, error) {
if time.Since(cpuPrevAt) > cpuStaleAfter {
idle, total, err := readCPUTicks()
if err != nil {
return 0, err
}
cpuPrevIdle, cpuPrevTotal = idle, total
time.Sleep(200 * time.Millisecond)
}
idle, total, err := readCPUTicks()
if err != nil {
return 0, err
}
dIdle, dTotal := idle-cpuPrevIdle, total-cpuPrevTotal
cpuPrevIdle, cpuPrevTotal, cpuPrevAt = idle, total, time.Now()
if dTotal == 0 || dIdle > dTotal {
return 0, nil
}
return clampPercent(100 * float64(dTotal-dIdle) / float64(dTotal)), nil
}
// readCPUTicks parses the aggregate "cpu" line of /proc/stat:
// user nice system idle iowait irq softirq steal ...
func readCPUTicks() (idle, total uint64, err error) {
var buf [512]byte
f, err := os.Open("/proc/stat")
if err != nil {
return 0, 0, err
}
n, _ := f.Read(buf[:])
f.Close()
line := buf[:n]
if i := bytes.IndexByte(line, '\n'); i >= 0 {
line = line[:i]
}
fields := bytes.Fields(line)
if len(fields) < 5 || string(fields[0]) != "cpu" {
return 0, 0, errors.New("unexpected /proc/stat")
}
for i, f := range fields[1:] {
if i >= 8 { // guest time is already counted in user/nice
break
}
v, _ := strconv.ParseUint(string(f), 10, 64)
total += v
if i == 3 || i == 4 { // idle, iowait
idle += v
}
}
return idle, total, nil
}
// memUsage reads /proc/meminfo; used is everything not available to new
// processes (so page cache does not count as used).
func memUsage() (total, used uint64, err error) {
data, err := os.ReadFile("/proc/meminfo")
if err != nil {
return 0, 0, err
}
var avail, free uint64
var haveAvail bool
for len(data) > 0 {
line := data
if i := bytes.IndexByte(data, '\n'); i >= 0 {
line, data = data[:i], data[i+1:]
} else {
data = nil
}
switch {
case bytes.HasPrefix(line, []byte("MemTotal:")):
total = meminfoKB(line)
case bytes.HasPrefix(line, []byte("MemFree:")):
free = meminfoKB(line)
case bytes.HasPrefix(line, []byte("MemAvailable:")):
avail, haveAvail = meminfoKB(line), true
}
if haveAvail && total > 0 {
break
}
}
if total == 0 {
return 0, 0, errors.New("unexpected /proc/meminfo")
}
if !haveAvail {
avail = free
}
if avail > total {
avail = total
}
return total, total - avail, nil
}
// meminfoKB returns the value of a "Key: 123 kB" line in bytes.
func meminfoKB(line []byte) uint64 {
f := bytes.Fields(line)
if len(f) < 2 {
return 0
}
v, _ := strconv.ParseUint(string(f[1]), 10, 64)
return v * 1024
}
+5 -2
View File
@@ -17,9 +17,12 @@ func (a *Agent) runTask(ctx context.Context, taskID string) {
return return
} }
var res proto.TaskResult var res proto.TaskResult
if env.Type == proto.TaskSysUpdate { switch env.Type {
case proto.TaskSysUpdate:
res = a.doUpdate() res = a.doUpdate()
} else { case proto.TaskSysSpeed:
res = a.doSpeed(ctx, env)
default:
res = a.execTask(env) res = a.execTask(env)
} }
a.postResult(ctx, taskID, res) a.postResult(ctx, taskID, res)
+1 -1
View File
@@ -15,7 +15,7 @@ type PTY struct {
} }
// StartPTY launches a login shell attached to a new PTY. With user empty (or // StartPTY launches a login shell attached to a new PTY. With user empty (or
// equal to the agent's own user) it is the agent's shell — root by default, // equal to the agent's own user) it is the agent's shell - root by default,
// since the agent runs as a root systemd service. Any other user is entered via // since the agent runs as a root systemd service. Any other user is entered via
// `su -l`, which only a root agent can do without a password. // `su -l`, which only a root agent can do without a password.
func StartPTY(user string) (*PTY, error) { func StartPTY(user string) (*PTY, error) {
+1 -1
View File
@@ -17,7 +17,7 @@ import (
// doUpdate downloads the latest agent binary for this platform, atomically // doUpdate downloads the latest agent binary for this platform, atomically
// replaces the running executable, and schedules a service restart. It never // replaces the running executable, and schedules a service restart. It never
// uninstalls — only updates. // uninstalls - only updates.
func (a *Agent) doUpdate() proto.TaskResult { func (a *Agent) doUpdate() proto.TaskResult {
exe, err := os.Executable() exe, err := os.Executable()
if err != nil { if err != nil {
+2
View File
@@ -33,6 +33,8 @@ func dispatchTask(env proto.TaskEnvelope) (any, error) {
switch env.Type { switch env.Type {
case proto.TaskSysUsers: case proto.TaskSysUsers:
return sysUsers(), nil return sysUsers(), nil
case proto.TaskSysStat:
return sysStat()
case proto.TaskSysUserAdd: case proto.TaskSysUserAdd:
var a proto.SysUserAddArgs var a proto.SysUserAddArgs
_ = json.Unmarshal(env.Args, &a) _ = json.Unmarshal(env.Args, &a)
+34
View File
@@ -89,6 +89,8 @@ const (
TaskSysUpdate = "sys.update" // download the latest agent binary and restart TaskSysUpdate = "sys.update" // download the latest agent binary and restart
TaskSysUsers = "sys.users" // report the agent's user and the login-capable users TaskSysUsers = "sys.users" // report the agent's user and the login-capable users
TaskSysUserAdd = "sys.useradd" // create a new OS user TaskSysUserAdd = "sys.useradd" // create a new OS user
TaskSysStat = "sys.stat" // CPU load, RAM and disk usage right now
TaskSysSpeed = "sys.speed" // measure the link to the server (ping, down, up)
) )
// FSReadCap bounds how many bytes TaskFSRead returns (5 MiB). The panel lets a // FSReadCap bounds how many bytes TaskFSRead returns (5 MiB). The panel lets a
@@ -166,3 +168,35 @@ type SysUserAddArgs struct {
Password string `json:"password,omitempty"` // empty leaves the account without a password Password string `json:"password,omitempty"` // empty leaves the account without a password
Sudo bool `json:"sudo"` // add to the sudo/wheel group Sudo bool `json:"sudo"` // add to the sudo/wheel group
} }
// SysStatResult is returned for TaskSysStat. Sizes are in bytes.
type SysStatResult struct {
CPU float64 `json:"cpu"` // load across all cores, 0..100
NCPU int `json:"ncpu"`
MemTotal uint64 `json:"mem_total"`
MemUsed uint64 `json:"mem_used"`
DiskTotal uint64 `json:"disk_total"` // filesystem holding "/"
DiskFree uint64 `json:"disk_free"`
}
// SysSpeedArgs are the arguments of TaskSysSpeed.
type SysSpeedArgs struct {
// Relay makes the agent use the relayed streams, which the server pipes
// to and from the browser.
Relay bool `json:"relay,omitempty"`
// WindowMs is how long to measure each direction for; 0 leaves it to the
// agent (3 s, all an agent that predates this argument does).
WindowMs int `json:"window_ms,omitempty"`
}
// SysSpeedResult is returned for TaskSysSpeed: the agent's HTTP link to the
// server as the agent measured it. Down is server -> agent.
type SysSpeedResult struct {
PingMs float64 `json:"ping_ms"`
DownBps float64 `json:"down_bps"` // bits per second
UpBps float64 `json:"up_bps"`
Relay bool `json:"relay,omitempty"` // the streams went through the relay
// Partial marks a test the server cut short: the numbers are what it had
// counted on its own side by then.
Partial bool `json:"partial,omitempty"`
}
+10
View File
@@ -24,6 +24,16 @@ func (s *Server) authenticateAgent(r *http.Request) *Agent {
return a return a
} }
// online reports whether the agent is connected right now: the hub knows from
// its control polls. Right after a start it cannot tell yet, so the last poll
// recorded in the database decides.
func (s *Server) online(a *Agent) bool {
if on, ok := s.hub.Online(a.ID); ok {
return on
}
return a.recentlySeen()
}
func bearerToken(r *http.Request) string { func bearerToken(r *http.Request) string {
h := r.Header.Get("Authorization") h := r.Header.Get("Authorization")
if strings.HasPrefix(h, "Bearer ") { if strings.HasPrefix(h, "Bearer ") {
+28 -10
View File
@@ -105,6 +105,8 @@ CREATE TABLE IF NOT EXISTS forward_rules (
`ALTER TABLE ufw_rules ADD COLUMN rule_id INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE ufw_rules ADD COLUMN rule_id INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE agents ADD COLUMN run_user TEXT NOT NULL DEFAULT ''`, `ALTER TABLE agents ADD COLUMN run_user TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE agents ADD COLUMN login_user TEXT NOT NULL DEFAULT ''`, `ALTER TABLE agents ADD COLUMN login_user TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE forward_rules ADD COLUMN no_ufw INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE forward_rules ADD COLUMN label TEXT NOT NULL DEFAULT ''`,
} { } {
if _, err := db.sql.Exec(alter); err != nil && !strings.Contains(err.Error(), "duplicate column") { if _, err := db.sql.Exec(alter); err != nil && !strings.Contains(err.Error(), "duplicate column") {
return err return err
@@ -238,8 +240,9 @@ func (a Agent) TerminalUser() string {
return firstNonEmpty(a.LoginUser, a.RunUser, "root") return firstNonEmpty(a.LoginUser, a.RunUser, "root")
} }
// Online reports whether the agent polled recently enough to be considered up. // recentlySeen is the fallback for "online" right after a server start, while
func (a Agent) Online() bool { // the hub has not seen the agents' polls yet (see Server.online).
func (a Agent) recentlySeen() bool {
return time.Since(a.LastSeen) < 45*time.Second return time.Since(a.LastSeen) < 45*time.Second
} }
@@ -327,6 +330,8 @@ type ForwardRule struct {
HostPort int HostPort int
ListenAddr string ListenAddr string
Proto string Proto string
NoUfw bool // the panel opens no ufw rule for this forward
Label string // optional note shown next to the rule
} }
func (db *DB) CreateForwardRule(r ForwardRule) (int64, error) { func (db *DB) CreateForwardRule(r ForwardRule) (int64, error) {
@@ -334,9 +339,9 @@ func (db *DB) CreateForwardRule(r ForwardRule) (int64, error) {
r.Proto = "http" r.Proto = "http"
} }
res, err := db.sql.Exec( res, err := db.sql.Exec(
`INSERT INTO forward_rules(agent_id, client_port, host_port, listen_addr, proto, created_at) `INSERT INTO forward_rules(agent_id, client_port, host_port, listen_addr, proto, no_ufw, label, created_at)
VALUES(?, ?, ?, ?, ?, ?)`, VALUES(?, ?, ?, ?, ?, ?, ?, ?)`,
r.AgentID, r.ClientPort, r.HostPort, r.ListenAddr, r.Proto, time.Now().Unix()) r.AgentID, r.ClientPort, r.HostPort, r.ListenAddr, r.Proto, r.NoUfw, r.Label, time.Now().Unix())
if err != nil { if err != nil {
return 0, err return 0, err
} }
@@ -345,7 +350,7 @@ func (db *DB) CreateForwardRule(r ForwardRule) (int64, error) {
func (db *DB) ListForwardRules() ([]ForwardRule, error) { func (db *DB) ListForwardRules() ([]ForwardRule, error) {
rows, err := db.sql.Query( rows, err := db.sql.Query(
`SELECT id, agent_id, client_port, host_port, listen_addr, proto FROM forward_rules ORDER BY id`) `SELECT id, agent_id, client_port, host_port, listen_addr, proto, no_ufw, label FROM forward_rules ORDER BY id`)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -353,7 +358,7 @@ func (db *DB) ListForwardRules() ([]ForwardRule, error) {
var out []ForwardRule var out []ForwardRule
for rows.Next() { for rows.Next() {
var r ForwardRule var r ForwardRule
if err := rows.Scan(&r.ID, &r.AgentID, &r.ClientPort, &r.HostPort, &r.ListenAddr, &r.Proto); err != nil { if err := rows.Scan(&r.ID, &r.AgentID, &r.ClientPort, &r.HostPort, &r.ListenAddr, &r.Proto, &r.NoUfw, &r.Label); err != nil {
return nil, err return nil, err
} }
out = append(out, r) out = append(out, r)
@@ -378,8 +383,8 @@ func (db *DB) ListForwardRulesByAgent(agentID string) ([]ForwardRule, error) {
func (db *DB) GetForwardRule(id int64) (*ForwardRule, error) { func (db *DB) GetForwardRule(id int64) (*ForwardRule, error) {
r := &ForwardRule{} r := &ForwardRule{}
err := db.sql.QueryRow( err := db.sql.QueryRow(
`SELECT id, agent_id, client_port, host_port, listen_addr, proto FROM forward_rules WHERE id = ?`, id). `SELECT id, agent_id, client_port, host_port, listen_addr, proto, no_ufw, label FROM forward_rules WHERE id = ?`, id).
Scan(&r.ID, &r.AgentID, &r.ClientPort, &r.HostPort, &r.ListenAddr, &r.Proto) Scan(&r.ID, &r.AgentID, &r.ClientPort, &r.HostPort, &r.ListenAddr, &r.Proto, &r.NoUfw, &r.Label)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, nil return nil, nil
} }
@@ -397,7 +402,20 @@ type UfwRule struct {
ID int64 ID int64
Port int Port int
Proto string Proto string
RuleID int64 // bound forward rule id, or 0 for shared/manual (e.g. tunnel 8446) RuleID int64 // bound forward rule id, 0 for shared (the tunnel port), ufwManual for one added in Settings
}
// ufwManual is the RuleID of a ufw rule the admin added by hand: no forward
// owns it, so only the admin removes it.
const ufwManual = -1
// Manual reports whether the admin added the rule by hand.
func (r UfwRule) Manual() bool { return r.RuleID == ufwManual }
// SetUfwRuleManual hands a tracked rule over to the admin.
func (db *DB) SetUfwRuleManual(port int, proto string) error {
_, err := db.sql.Exec(`UPDATE ufw_rules SET rule_id = ? WHERE port = ? AND proto = ?`, ufwManual, port, proto)
return err
} }
func (db *DB) AddUfwRule(port int, proto string, ruleID int64) error { func (db *DB) AddUfwRule(port int, proto string, ruleID int64) error {
+4 -4
View File
@@ -14,8 +14,8 @@ import (
// supports three transports: // supports three transports:
// - "http": tunneled over the HTTP long-poll channel (works anywhere nginx // - "http": tunneled over the HTTP long-poll channel (works anywhere nginx
// reaches, highest latency); // reaches, highest latency);
// - "tcp": direct TCP multiplexed over the 8446 tunnel (low latency); // - "tcp": direct TCP multiplexed over the tunnel port (low latency);
// - "udp": direct UDP over the 8446 channel. // - "udp": direct UDP over the tunnel port.
type forwardManager struct { type forwardManager struct {
hub *Hub hub *Hub
tun *tunnelHub tun *tunnelHub
@@ -92,11 +92,11 @@ func (m *forwardManager) acceptTCP(rule ForwardRule, ln net.Listener) {
} }
} }
// bridgeDirect wires a connection to the agent's low-latency 8446 tunnel. // bridgeDirect wires a connection to the agent's low-latency direct tunnel.
func (m *forwardManager) bridgeDirect(rule ForwardRule, conn net.Conn) { func (m *forwardManager) bridgeDirect(rule ForwardRule, conn net.Conn) {
at := m.tun.get(rule.AgentID) at := m.tun.get(rule.AgentID)
if at == nil { if at == nil {
// No direct tunnel (agent offline or 8446 unreachable). // No direct tunnel (agent offline or the tunnel port unreachable).
conn.Close() conn.Close()
return return
} }
+8 -8
View File
@@ -16,7 +16,7 @@ func writeJSON(w http.ResponseWriter, v any) {
_ = json.NewEncoder(w).Encode(v) _ = json.NewEncoder(w).Encode(v)
} }
// POST /api/agent/enroll — exchange the shared enroll secret for a per-agent id+token. // POST /api/agent/enroll - exchange the shared enroll secret for a per-agent id+token.
func (s *Server) handleEnroll(w http.ResponseWriter, r *http.Request) { func (s *Server) handleEnroll(w http.ResponseWriter, r *http.Request) {
secret := r.Header.Get("X-Enroll-Secret") secret := r.Header.Get("X-Enroll-Secret")
if !constantTimeEqual(secret, s.db.EnrollSecret()) { if !constantTimeEqual(secret, s.db.EnrollSecret()) {
@@ -36,7 +36,7 @@ func (s *Server) handleEnroll(w http.ResponseWriter, r *http.Request) {
writeJSON(w, proto.EnrollResponse{AgentID: id, Token: token}) writeJSON(w, proto.EnrollResponse{AgentID: id, Token: token})
} }
// GET /api/agent/poll — control channel. Held until a job is queued or timeout. // GET /api/agent/poll - control channel. Held until a job is queued or timeout.
func (s *Server) handlePoll(w http.ResponseWriter, r *http.Request) { func (s *Server) handlePoll(w http.ResponseWriter, r *http.Request) {
a := s.authenticateAgent(r) a := s.authenticateAgent(r)
if a == nil { if a == nil {
@@ -52,7 +52,7 @@ func (s *Server) handlePoll(w http.ResponseWriter, r *http.Request) {
} else { } else {
_ = s.db.TouchAgent(a.ID) _ = s.db.TouchAgent(a.ID)
} }
msg := s.hub.WaitControl(a.ID, pollHold) msg := s.hub.WaitControl(r.Context().Done(), a.ID, pollHold)
// Set after the hold so a port changed meanwhile reaches the agent now. // Set after the hold so a port changed meanwhile reaches the agent now.
s.setTunnelHeader(w) s.setTunnelHeader(w)
writeJSON(w, msg) writeJSON(w, msg)
@@ -68,7 +68,7 @@ func (s *Server) setTunnelHeader(w http.ResponseWriter) {
} }
} }
// GET /api/agent/config — settings an agent picks up when it connects, without // GET /api/agent/config - settings an agent picks up when it connects, without
// waiting out a poll hold. // waiting out a poll hold.
func (s *Server) handleAgentConfig(w http.ResponseWriter, r *http.Request) { func (s *Server) handleAgentConfig(w http.ResponseWriter, r *http.Request) {
if s.authenticateAgent(r) == nil { if s.authenticateAgent(r) == nil {
@@ -79,7 +79,7 @@ func (s *Server) handleAgentConfig(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
// GET /api/agent/task/{id} — the agent fetches the task to run. // GET /api/agent/task/{id} - the agent fetches the task to run.
func (s *Server) handleTaskFetch(w http.ResponseWriter, r *http.Request) { func (s *Server) handleTaskFetch(w http.ResponseWriter, r *http.Request) {
if s.authenticateAgent(r) == nil { if s.authenticateAgent(r) == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized) http.Error(w, "unauthorized", http.StatusUnauthorized)
@@ -93,7 +93,7 @@ func (s *Server) handleTaskFetch(w http.ResponseWriter, r *http.Request) {
writeJSON(w, env) writeJSON(w, env)
} }
// POST /api/agent/task/{id}/result — the agent returns the task result. // POST /api/agent/task/{id}/result - the agent returns the task result.
func (s *Server) handleTaskResult(w http.ResponseWriter, r *http.Request) { func (s *Server) handleTaskResult(w http.ResponseWriter, r *http.Request) {
if s.authenticateAgent(r) == nil { if s.authenticateAgent(r) == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized) http.Error(w, "unauthorized", http.StatusUnauthorized)
@@ -108,7 +108,7 @@ func (s *Server) handleTaskResult(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
// GET /api/agent/term/{sid}/in — downstream (panel -> agent) long-poll. // GET /api/agent/term/{sid}/in - downstream (panel -> agent) long-poll.
func (s *Server) handleTermIn(w http.ResponseWriter, r *http.Request) { func (s *Server) handleTermIn(w http.ResponseWriter, r *http.Request) {
if s.authenticateAgent(r) == nil { if s.authenticateAgent(r) == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized) http.Error(w, "unauthorized", http.StatusUnauthorized)
@@ -127,7 +127,7 @@ func (s *Server) handleTermIn(w http.ResponseWriter, r *http.Request) {
writeJSON(w, proto.InBatch{Frames: frames}) writeJSON(w, proto.InBatch{Frames: frames})
} }
// POST /api/agent/term/{sid}/out — upstream (agent PTY -> panel). // POST /api/agent/term/{sid}/out - upstream (agent PTY -> panel).
func (s *Server) handleTermOut(w http.ResponseWriter, r *http.Request) { func (s *Server) handleTermOut(w http.ResponseWriter, r *http.Request) {
if s.authenticateAgent(r) == nil { if s.authenticateAgent(r) == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized) http.Error(w, "unauthorized", http.StatusUnauthorized)
+71 -9
View File
@@ -118,7 +118,8 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
} }
rows := make([]row, 0, len(agents)) rows := make([]row, 0, len(agents))
for _, a := range agents { for _, a := range agents {
rows = append(rows, row{Agent: a, Online: a.Online(), Seen: humanSince(a.LastSeen)}) on := s.online(&a)
rows = append(rows, row{Agent: a, Online: on, Seen: seenText(on, a.LastSeen)})
} }
s.render(w, "dashboard.html", map[string]any{ s.render(w, "dashboard.html", map[string]any{
"Admin": s.currentAdmin(r), "Admin": s.currentAdmin(r),
@@ -127,6 +128,36 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
}) })
} }
// seenText is the "activity" cell: a connected agent is active now, whatever
// the time of its last recorded poll.
func seenText(online bool, lastSeen time.Time) string {
if online {
return "сейчас"
}
return humanSince(lastSeen)
}
// GET /api/panel/agents/presence - who is online, for the dashboard to keep
// its status column live without reloading.
func (s *Server) handlePresence(w http.ResponseWriter, r *http.Request) {
agents, err := s.db.ListAgents()
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
type item struct {
ID string `json:"id"`
Online bool `json:"online"`
Seen string `json:"seen"`
}
out := make([]item, 0, len(agents))
for _, a := range agents {
on := s.online(&a)
out = append(out, item{ID: a.ID, Online: on, Seen: seenText(on, a.LastSeen)})
}
writeJSON(w, out)
}
func (s *Server) handleTerminalPage(w http.ResponseWriter, r *http.Request) { func (s *Server) handleTerminalPage(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") id := r.PathValue("id")
a, err := s.db.GetAgent(id) a, err := s.db.GetAgent(id)
@@ -146,7 +177,16 @@ func (s *Server) handleAgentDelete(w http.ResponseWriter, r *http.Request) {
return return
} }
id := r.PathValue("id") id := r.PathValue("id")
// Its forward rules go with it: left behind they would keep their host
// ports bound and busy with no page to remove them from.
rules, _ := s.db.ListForwardRulesByAgent(id)
for _, rule := range rules {
s.removeForward(rule.ID)
}
_ = s.db.DeleteAgent(id) _ = s.db.DeleteAgent(id)
s.stats.drop(id)
s.relays.drop(id, nil)
s.hub.Forget(id)
http.Redirect(w, r, "/", http.StatusSeeOther) http.Redirect(w, r, "/", http.StatusSeeOther)
} }
@@ -167,7 +207,7 @@ func (s *Server) handleAgentEdit(w http.ResponseWriter, r *http.Request) {
"UserErr": r.URL.Query().Get("uerr"), "UserErr": r.URL.Query().Get("uerr"),
} }
// The user list lives on the machine, so it is only known while it is online. // The user list lives on the machine, so it is only known while it is online.
if a.Online() { if s.online(a) {
if users, err := s.agentUsers(id); err == nil { if users, err := s.agentUsers(id); err == nil {
data["Users"] = users data["Users"] = users
} else { } else {
@@ -188,7 +228,7 @@ func (s *Server) agentUsers(agentID string) (*proto.SysUsersResult, error) {
case res := <-ch: case res := <-ch:
if !res.OK { if !res.OK {
// Agents older than 0.0.2 don't know this task. // Agents older than 0.0.2 don't know this task.
return nil, errors.New("клиент не поддерживает выбор пользователя — обновите его") return nil, errors.New("клиент не поддерживает выбор пользователя - обновите его")
} }
var out proto.SysUsersResult var out proto.SysUsersResult
if err := json.Unmarshal(res.Data, &out); err != nil { if err := json.Unmarshal(res.Data, &out); err != nil {
@@ -218,8 +258,8 @@ func (s *Server) handleAgentSetUser(w http.ResponseWriter, r *http.Request) {
return return
} }
name := strings.TrimSpace(r.FormValue("user")) name := strings.TrimSpace(r.FormValue("user"))
if !a.Online() { if !s.online(a) {
editUserRedirect(w, r, id, "uerr", "Машина офлайн — сменить пользователя нельзя.") editUserRedirect(w, r, id, "uerr", "Машина офлайн - сменить пользователя нельзя.")
return return
} }
// Only accept a user the machine itself reports, so a typo (or an agent too // Only accept a user the machine itself reports, so a typo (or an agent too
@@ -259,8 +299,8 @@ func (s *Server) handleAgentAddUser(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
if !a.Online() { if !s.online(a) {
editUserRedirect(w, r, id, "uerr", "Машина офлайн — создать пользователя нельзя.") editUserRedirect(w, r, id, "uerr", "Машина офлайн - создать пользователя нельзя.")
return return
} }
name := strings.TrimSpace(r.FormValue("name")) name := strings.TrimSpace(r.FormValue("name"))
@@ -300,7 +340,7 @@ func (s *Server) handleAgentUpdate(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
if !a.Online() { if !s.online(a) {
http.Redirect(w, r, "/agents/"+id+"/edit?updated=0", http.StatusSeeOther) http.Redirect(w, r, "/agents/"+id+"/edit?updated=0", http.StatusSeeOther)
return return
} }
@@ -358,12 +398,16 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
"Error": map[string]string{ "Error": map[string]string{
"tunnel_host": "Некорректный хост туннеля: нужен IP или имя без схемы и порта.", "tunnel_host": "Некорректный хост туннеля: нужен IP или имя без схемы и порта.",
"tunnel_port": "Некорректный порт туннеля.", "tunnel_port": "Некорректный порт туннеля.",
"ufw_port": "Некорректное правило ufw: нужен порт 1-65535 и протокол tcp или udp.",
"ufw_off": "Правило ufw не добавлено: ufw выключен или интеграция с ним отключена.",
"ufw_failed": "Правило ufw не добавлено: команда ufw завершилась ошибкой.",
"ufw_exists": "Этот порт уже открыт в ufw не панелью - правило осталось как было, панель его не отслеживает.",
"tunnel_listen": "Не удалось занять новый порт туннеля на хосте (возможно, уже используется). Порт не изменён.", "tunnel_listen": "Не удалось занять новый порт туннеля на хосте (возможно, уже используется). Порт не изменён.",
}[r.URL.Query().Get("err")], }[r.URL.Query().Get("err")],
}) })
} }
// POST /settings/tunnel — host and port agents use for the direct tunnel. // POST /settings/tunnel - host and port agents use for the direct tunnel.
func (s *Server) handleSetTunnel(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSetTunnel(w http.ResponseWriter, r *http.Request) {
if !checkCSRF(r) { if !checkCSRF(r) {
http.Error(w, "bad csrf", http.StatusForbidden) http.Error(w, "bad csrf", http.StatusForbidden)
@@ -485,6 +529,24 @@ func (s *Server) handleUfwToggle(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/settings", http.StatusSeeOther) http.Redirect(w, r, "/settings", http.StatusSeeOther)
} }
func (s *Server) handleUfwAdd(w http.ResponseWriter, r *http.Request) {
if !checkCSRF(r) {
http.Error(w, "bad csrf", http.StatusForbidden)
return
}
port, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("port")))
proto := r.FormValue("proto")
if port < 1 || port > 65535 || (proto != "tcp" && proto != "udp") {
http.Redirect(w, r, "/settings?err=ufw_port", http.StatusSeeOther)
return
}
target := "/settings"
if key := s.ufw.allowManual(port, proto); key != "" {
target += "?err=" + key
}
http.Redirect(w, r, target, http.StatusSeeOther)
}
func (s *Server) handleUfwRevoke(w http.ResponseWriter, r *http.Request) { func (s *Server) handleUfwRevoke(w http.ResponseWriter, r *http.Request) {
if !checkCSRF(r) { if !checkCSRF(r) {
http.Error(w, "bad csrf", http.StatusForbidden) http.Error(w, "bad csrf", http.StatusForbidden)
+4 -4
View File
@@ -37,7 +37,7 @@ func (s *Server) handleFilesPage(w http.ResponseWriter, r *http.Request) {
s.render(w, "files.html", map[string]any{"Agent": a, "EditLimit": proto.FSReadCap}) s.render(w, "files.html", map[string]any{"Agent": a, "EditLimit": proto.FSReadCap})
} }
// POST /api/panel/agents/{id}/fs/{op} — run a file-manager operation on the // POST /api/panel/agents/{id}/fs/{op} - run a file-manager operation on the
// agent and return its result. The request body is the op's JSON args and is // agent and return its result. The request body is the op's JSON args and is
// forwarded verbatim to the agent. // forwarded verbatim to the agent.
func (s *Server) handleFSOp(w http.ResponseWriter, r *http.Request) { func (s *Server) handleFSOp(w http.ResponseWriter, r *http.Request) {
@@ -53,7 +53,7 @@ func (s *Server) handleFSOp(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
if !a.Online() { if !s.online(a) {
writeJSON(w, proto.TaskResult{Error: "машина офлайн"}) writeJSON(w, proto.TaskResult{Error: "машина офлайн"})
return return
} }
@@ -74,7 +74,7 @@ func (s *Server) handleFSOp(w http.ResponseWriter, r *http.Request) {
} }
} }
// GET /api/panel/agents/{id}/fs/download?path=... — stream a file as a download. // GET /api/panel/agents/{id}/fs/download?path=... - stream a file as a download.
func (s *Server) handleFSDownload(w http.ResponseWriter, r *http.Request) { func (s *Server) handleFSDownload(w http.ResponseWriter, r *http.Request) {
agentID := r.PathValue("id") agentID := r.PathValue("id")
p := r.URL.Query().Get("path") p := r.URL.Query().Get("path")
@@ -83,7 +83,7 @@ func (s *Server) handleFSDownload(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
if !a.Online() { if !s.online(a) {
http.Error(w, "машина офлайн", http.StatusServiceUnavailable) http.Error(w, "машина офлайн", http.StatusServiceUnavailable)
return return
} }
+31 -7
View File
@@ -18,6 +18,7 @@ func (s *Server) handleForwardsPage(w http.ResponseWriter, r *http.Request) {
rules, _ := s.db.ListForwardRulesByAgent(id) rules, _ := s.db.ListForwardRulesByAgent(id)
errMsg := map[string]string{ errMsg := map[string]string{
"busy": "Этот порт хоста уже занят другим правилом.", "busy": "Этот порт хоста уже занят другим правилом.",
"tunnel": "Этот порт занят прямым туннелем панели (см. Настройки).",
"port": "Некорректный номер порта.", "port": "Некорректный номер порта.",
"listen": "Не удалось занять порт на хосте (возможно, уже используется).", "listen": "Не удалось занять порт на хосте (возможно, уже используется).",
"db": "Ошибка базы данных.", "db": "Ошибка базы данных.",
@@ -29,6 +30,7 @@ func (s *Server) handleForwardsPage(w http.ResponseWriter, r *http.Request) {
"Error": errMsg, "Error": errMsg,
"TunnelPort": s.TunnelPort(), "TunnelPort": s.TunnelPort(),
"PanelPort": panelPort(s.db.HostAddress()), "PanelPort": panelPort(s.db.HostAddress()),
"UfwEnabled": s.ufw.enabled(),
}) })
} }
@@ -76,11 +78,16 @@ func (s *Server) handleForwardAdd(w http.ResponseWriter, r *http.Request) {
return return
} }
// Reject a host port already bound by another forward rule or the tunnel. // Reject a host port already bound by another forward rule or the tunnel.
if s.db.HostPortUsed(hostPort) || hostPort == s.TunnelPort() { if hostPort == s.TunnelPort() {
http.Redirect(w, r, "/agents/"+id+"/forwards?err=tunnel", http.StatusSeeOther)
return
}
if s.db.HostPortUsed(hostPort) {
http.Redirect(w, r, "/agents/"+id+"/forwards?err=busy", http.StatusSeeOther) http.Redirect(w, r, "/agents/"+id+"/forwards?err=busy", http.StatusSeeOther)
return return
} }
rule := ForwardRule{AgentID: id, ClientPort: clientPort, HostPort: hostPort, ListenAddr: listenAddr, Proto: protoKind} rule := ForwardRule{AgentID: id, ClientPort: clientPort, HostPort: hostPort, ListenAddr: listenAddr, Proto: protoKind,
NoUfw: r.FormValue("no_ufw") != "", Label: forwardLabel(r.FormValue("label"))}
ruleID, err := s.db.CreateForwardRule(rule) ruleID, err := s.db.CreateForwardRule(rule)
if err != nil { if err != nil {
http.Redirect(w, r, "/agents/"+id+"/forwards?err=db", http.StatusSeeOther) http.Redirect(w, r, "/agents/"+id+"/forwards?err=db", http.StatusSeeOther)
@@ -107,14 +114,31 @@ func (s *Server) handleForwardDelete(w http.ResponseWriter, r *http.Request) {
} }
rid, _ := strconv.ParseInt(r.PathValue("rid"), 10, 64) rid, _ := strconv.ParseInt(r.PathValue("rid"), 10, 64)
rule, _ := s.db.GetForwardRule(rid) rule, _ := s.db.GetForwardRule(rid)
s.fwd.close(rid) s.removeForward(rid)
_ = s.db.DeleteForwardRule(rid)
// Remove the ufw rule bound to this forward rule (the shared tunnel port
// stays while another direct forward needs it).
s.ufw.revokeForRule(rid)
dest := "/" dest := "/"
if rule != nil { if rule != nil {
dest = "/agents/" + rule.AgentID + "/forwards" dest = "/agents/" + rule.AgentID + "/forwards"
} }
http.Redirect(w, r, dest, http.StatusSeeOther) http.Redirect(w, r, dest, http.StatusSeeOther)
} }
// removeForward closes a forward rule's listener and deletes the rule, along
// with the ufw rule bound to it (the shared tunnel port stays while another
// direct forward needs it).
func (s *Server) removeForward(rid int64) {
s.fwd.close(rid)
_ = s.db.DeleteForwardRule(rid)
s.ufw.revokeForRule(rid)
}
// forwardLabelMax bounds a rule's label, in characters.
const forwardLabelMax = 80
// forwardLabel tidies the optional note of a rule: one line, bounded length.
func forwardLabel(v string) string {
v = strings.Join(strings.Fields(v), " ")
if r := []rune(v); len(r) > forwardLabelMax {
v = string(r[:forwardLabelMax])
}
return v
}
+542
View File
@@ -0,0 +1,542 @@
package server
import (
"crypto/rand"
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/x-grk/longpolling-remote-access/internal/proto"
)
const (
// statTaskTimeout bounds how long a status request waits for the agent.
statTaskTimeout = 8 * time.Second
// statCacheFor is how long one sample answers every open status page, so
// several tabs cost the agent a single reading.
statCacheFor = 2 * time.Second
// speedWindow is how long the agent is asked to measure each direction
// for. An agent that predates the argument keeps to its own 3 s.
speedWindow = 7 * time.Second
// speedTaskTimeout is how long a speed test may take. One that is not done
// by then is answered with what the server has counted itself.
speedTaskTimeout = 35 * time.Second
// speedDownCap bounds one download stream; speedDownFor ends it if the
// agent does not hang up first.
speedDownCap = 1 << 30
speedDownFor = 2 * speedWindow
speedUpCap = 64 << 20
// speedRelayUpFor is how long the agent's upload is passed on to the
// browser: the agent's own window with slack.
speedRelayUpFor = speedWindow + time.Second
)
// statCache holds the last sample per agent and serializes its refresh.
type statCache struct {
mu sync.Mutex
m map[string]*statEntry
}
type statEntry struct {
mu sync.Mutex
at time.Time
res proto.TaskResult
}
func (c *statCache) entry(agentID string) *statEntry {
c.mu.Lock()
defer c.mu.Unlock()
if c.m == nil {
c.m = map[string]*statEntry{}
}
e := c.m[agentID]
if e == nil {
e = &statEntry{}
c.m[agentID] = e
}
return e
}
func (c *statCache) drop(agentID string) {
c.mu.Lock()
delete(c.m, agentID)
c.mu.Unlock()
}
func (s *Server) handleStatusPage(w http.ResponseWriter, r *http.Request) {
a, err := s.db.GetAgent(r.PathValue("id"))
if err != nil || a == nil {
http.NotFound(w, r)
return
}
s.render(w, "status.html", map[string]any{"Agent": a, "RelayUpMs": speedRelayUpFor.Milliseconds()})
}
// runTask runs a one-shot task on the agent and waits for its result.
func (s *Server) runTask(agentID, typ string, args []byte, timeout time.Duration) (proto.TaskResult, error) {
taskID, ch := s.hub.CreateTask(agentID, typ, args)
defer s.hub.DiscardTask(taskID)
t := time.NewTimer(timeout)
defer t.Stop()
select {
case res := <-ch:
return res, nil
case <-t.C:
return proto.TaskResult{}, errors.New("таймаут ожидания клиента")
}
}
// onlineAgent answers the request itself and returns false when the agent is
// unknown or offline.
func (s *Server) onlineAgent(w http.ResponseWriter, r *http.Request) (string, bool) {
id := r.PathValue("id")
a, err := s.db.GetAgent(id)
if err != nil || a == nil {
http.NotFound(w, r)
return "", false
}
if !s.online(a) {
writeJSON(w, proto.TaskResult{Error: "машина офлайн"})
return "", false
}
return id, true
}
// oldAgentError replaces the "unknown task" answer of an agent that predates
// the task.
func oldAgentError(res proto.TaskResult) proto.TaskResult {
if !res.OK && strings.Contains(res.Error, "неизвестная операция") {
res.Error = "клиент не поддерживает эту функцию - обновите его (нужен 0.0.5+)"
}
return res
}
// GET /api/panel/agents/{id}/stat - CPU, RAM and disk of the machine right now.
func (s *Server) handleAgentStat(w http.ResponseWriter, r *http.Request) {
id, ok := s.onlineAgent(w, r)
if !ok {
return
}
e := s.stats.entry(id)
e.mu.Lock()
defer e.mu.Unlock()
if time.Since(e.at) > statCacheFor {
res, err := s.runTask(id, proto.TaskSysStat, nil, statTaskTimeout)
if err != nil {
// Cached too, so pages queued behind this one don't each wait it out.
res = proto.TaskResult{Error: err.Error()}
}
e.res, e.at = oldAgentError(res), time.Now()
}
writeJSON(w, e.res)
}
// POST /api/panel/agents/{id}/speed - have the agent measure its link to us.
// With relay=1 its streams run through the relay opened for it, to and from
// the browser.
func (s *Server) handleAgentSpeed(w http.ResponseWriter, r *http.Request) {
id, ok := s.onlineAgent(w, r)
if !ok {
return
}
relay := r.URL.Query().Get("relay") != ""
m := s.meters.start(id)
defer s.meters.stop(id, m)
if relay {
rl := s.relays.get(id)
if rl == nil {
writeJSON(w, proto.TaskResult{Error: "замер через браузер не открыт"})
return
}
defer s.relays.drop(id, rl)
}
args, _ := json.Marshal(proto.SysSpeedArgs{Relay: relay, WindowMs: int(speedWindow.Milliseconds())})
res, err := s.runTask(id, proto.TaskSysSpeed, args, speedTaskTimeout)
if err != nil {
part, ok := m.result()
if !ok {
writeJSON(w, proto.TaskResult{Error: err.Error()})
return
}
part.Relay = relay
data, _ := json.Marshal(part)
res = proto.TaskResult{OK: true, Data: data}
}
res = oldAgentError(res)
if relay && res.OK {
// An agent that predates the relay ignores the argument and measures
// its own link.
var d proto.SysSpeedResult
if json.Unmarshal(res.Data, &d) != nil || !d.Relay {
res = proto.TaskResult{Error: "клиент не умеет замер через браузер - обновите его (нужен 0.0.6+)"}
}
}
writeJSON(w, res)
}
// speedMeter counts what flows through the streams of one speed test as the
// server sees it: for the live reading on the page, and for the result of a
// test that ran out of time.
type speedMeter struct {
mu sync.Mutex
start time.Time
phase string // "ping", "down", "up"
pingAt time.Time
pingMs float64
down speedCount
up speedCount
}
// speedCount is the bytes of one direction and when they went through. The
// first piece only starts the clock.
type speedCount struct {
n, skip int64
first, last time.Time
}
func (c *speedCount) add(n int) {
now := time.Now()
if c.first.IsZero() {
c.first, c.skip = now, int64(n)
}
c.n += int64(n)
c.last = now
}
func (c *speedCount) bps() float64 {
d := c.last.Sub(c.first).Seconds()
if d <= 0 {
return 0
}
return float64(c.n-c.skip) * 8 / d
}
// ping notes one of the agent's ping probes: they come one right after
// another, so the gap between two is its round trip.
func (m *speedMeter) ping() {
if m == nil {
return
}
m.mu.Lock()
now := time.Now()
if !m.pingAt.IsZero() {
if ms := float64(now.Sub(m.pingAt).Microseconds()) / 1000; m.pingMs == 0 || ms < m.pingMs {
m.pingMs = ms
}
}
m.pingAt = now
m.mu.Unlock()
}
func (m *speedMeter) add(up bool, n int) {
if m == nil || n <= 0 {
return
}
m.mu.Lock()
if up {
m.phase = "up"
m.up.add(n)
} else {
m.phase = "down"
m.down.add(n)
}
m.mu.Unlock()
}
// result is the test as counted so far; false with nothing measured yet.
func (m *speedMeter) result() (proto.SysSpeedResult, bool) {
m.mu.Lock()
defer m.mu.Unlock()
res := proto.SysSpeedResult{PingMs: m.pingMs, DownBps: m.down.bps(), UpBps: m.up.bps(), Partial: true}
return res, res.DownBps > 0
}
// meterWriter counts what it passes on; a nil meter counts nothing.
type meterWriter struct {
w io.Writer
m *speedMeter
up bool
}
func (c meterWriter) Write(p []byte) (int, error) {
n, err := c.w.Write(p)
c.m.add(c.up, n)
return n, err
}
// speedMeters holds the meter of each agent under test.
type speedMeters struct {
mu sync.Mutex
m map[string]*speedMeter
}
func (c *speedMeters) start(agentID string) *speedMeter {
m := &speedMeter{start: time.Now(), phase: "ping"}
c.mu.Lock()
if c.m == nil {
c.m = map[string]*speedMeter{}
}
c.m[agentID] = m
c.mu.Unlock()
return m
}
func (c *speedMeters) get(agentID string) *speedMeter {
c.mu.Lock()
defer c.mu.Unlock()
return c.m[agentID]
}
// stop forgets the agent's meter if it is still m.
func (c *speedMeters) stop(agentID string, m *speedMeter) {
c.mu.Lock()
if c.m[agentID] == m {
delete(c.m, agentID)
}
c.mu.Unlock()
}
// GET /api/panel/agents/{id}/speed/progress - the bytes counted so far in the
// agent's running speed test, for the page to turn into a live rate. An empty
// phase means no test is running.
func (s *Server) handleSpeedProgress(w http.ResponseWriter, r *http.Request) {
var out struct {
Phase string `json:"phase"`
Down int64 `json:"down"`
Up int64 `json:"up"`
Ms int64 `json:"ms"` // since the test began, by the server's clock
}
if m := s.meters.get(r.PathValue("id")); m != nil {
m.mu.Lock()
out.Phase, out.Down, out.Up = m.phase, m.down.n, m.up.n
out.Ms = time.Since(m.start).Milliseconds()
m.mu.Unlock()
}
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, out)
}
// speedBlock is one chunk of incompressible bytes, made once and sent over and
// over, so a speed test costs the server no CPU beyond the copy.
var speedBlock = sync.OnceValue(func() []byte {
b := make([]byte, 64<<10)
rand.Read(b)
return b
})
func speedStreamHeaders(w http.ResponseWriter) {
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Cache-Control", "no-store")
// Keep a proxy in front from buffering or compressing the stream.
w.Header().Set("X-Accel-Buffering", "no")
}
// GET /api/agent/speed/down?bytes=N - stream N bytes (default: as much as fits
// in the time window) for the agent to time. bytes=0 is the ping probe.
func (s *Server) handleSpeedDown(w http.ResponseWriter, r *http.Request) {
a := s.authenticateAgent(r)
if a == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
m := s.meters.get(a.ID)
left := speedDownCap
if v := r.URL.Query().Get("bytes"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 && n < left {
left = n
}
}
if left == 0 {
m.ping()
}
speedStreamHeaders(w)
block := speedBlock()
deadline := time.Now().Add(speedDownFor)
for left > 0 && time.Now().Before(deadline) && r.Context().Err() == nil {
chunk := block
if len(chunk) > left {
chunk = chunk[:left]
}
n, err := w.Write(chunk)
if err != nil {
return
}
m.add(false, n)
left -= n
}
}
// POST /api/agent/speed/up - swallow the agent's upload.
func (s *Server) handleSpeedUp(w http.ResponseWriter, r *http.Request) {
a := s.authenticateAgent(r)
if a == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
sink := meterWriter{w: io.Discard, m: s.meters.get(a.ID), up: true}
_, _ = io.Copy(sink, http.MaxBytesReader(w, r.Body, speedUpCap))
w.WriteHeader(http.StatusNoContent)
}
// speedRelay joins the agent's speed test streams to the browser's, so the
// bytes travel browser - panel - client. Both pipes are unbuffered: a slow
// reader holds its writer back, and the rate is that of the whole path.
type speedRelay struct {
downR *io.PipeReader // browser -> agent
downW *io.PipeWriter
upR *io.PipeReader // agent -> browser
upW *io.PipeWriter
}
func (rl *speedRelay) close() {
rl.downR.CloseWithError(io.ErrClosedPipe)
rl.downW.CloseWithError(io.ErrClosedPipe)
rl.upR.CloseWithError(io.ErrClosedPipe)
rl.upW.CloseWithError(io.ErrClosedPipe)
}
// speedRelays holds the relay of each agent under test, one per agent.
type speedRelays struct {
mu sync.Mutex
m map[string]*speedRelay
}
// open starts a fresh relay for the agent, ending the one it had.
func (c *speedRelays) open(agentID string) {
rl := &speedRelay{}
rl.downR, rl.downW = io.Pipe()
rl.upR, rl.upW = io.Pipe()
c.mu.Lock()
if c.m == nil {
c.m = map[string]*speedRelay{}
}
old := c.m[agentID]
c.m[agentID] = rl
c.mu.Unlock()
if old != nil {
old.close()
}
}
func (c *speedRelays) get(agentID string) *speedRelay {
c.mu.Lock()
defer c.mu.Unlock()
return c.m[agentID]
}
// drop ends the agent's relay; with rl given, only if it is still that one.
func (c *speedRelays) drop(agentID string, rl *speedRelay) {
c.mu.Lock()
cur := c.m[agentID]
if cur != nil && (rl == nil || rl == cur) {
delete(c.m, agentID)
} else {
cur = nil
}
c.mu.Unlock()
if cur != nil {
cur.close()
}
}
// GET /api/panel/ping - an empty answer for the browser to time.
func (s *Server) handlePanelPing(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusNoContent)
}
// POST /api/panel/agents/{id}/speed/relay - open the relay of a speed test
// that runs through the browser. The test itself ends it.
func (s *Server) handleSpeedRelayOpen(w http.ResponseWriter, r *http.Request) {
id, ok := s.onlineAgent(w, r)
if !ok {
return
}
s.relays.open(id)
writeJSON(w, proto.TaskResult{OK: true})
}
// POST /api/panel/agents/{id}/speed/down - a piece of the browser's upload,
// passed on to the agent. 204 asks for the next piece, 200 says the agent has
// stopped reading.
func (s *Server) handleSpeedRelayFeed(w http.ResponseWriter, r *http.Request) {
body := http.MaxBytesReader(w, r.Body, speedUpCap)
rl := s.relays.get(r.PathValue("id"))
if rl != nil {
if _, err := io.Copy(rl.downW, body); err == nil {
w.WriteHeader(http.StatusNoContent)
return
}
}
// Take the rest of the piece before answering: an answer sent over an
// unread body makes Go drop the connection, which a proxy in front turns
// into a 502.
_, _ = io.Copy(io.Discard, body)
w.Header().Set("Cache-Control", "no-store")
_, _ = io.WriteString(w, "done")
}
// GET /api/panel/agents/{id}/speed/up - the agent's upload, for the browser to
// time. Ends when the agent is done sending.
func (s *Server) handleSpeedRelayDrain(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
rl := s.relays.get(id)
if rl == nil {
http.Error(w, "gone", http.StatusGone)
return
}
speedStreamHeaders(w)
_, _ = io.Copy(meterWriter{w: w, m: s.meters.get(id), up: true}, rl.upR)
// The browser may have left: don't let the agent keep sending to nobody.
rl.upR.CloseWithError(io.ErrClosedPipe)
}
// GET /api/agent/speed/relay/down - what the browser uploads, for the agent to
// time.
func (s *Server) handleSpeedRelayDown(w http.ResponseWriter, r *http.Request) {
a := s.authenticateAgent(r)
if a == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
rl := s.relays.get(a.ID)
if rl == nil {
http.NotFound(w, r)
return
}
speedStreamHeaders(w)
// The agent waits on the headers, the browser may not have sent a byte yet.
_ = http.NewResponseController(w).Flush()
_, _ = io.Copy(meterWriter{w: w, m: s.meters.get(a.ID)}, rl.downR)
// The agent hung up at the end of its window: stop the browser's upload.
rl.downR.CloseWithError(io.ErrClosedPipe)
}
// POST /api/agent/speed/relay/up - the agent's upload, passed on to the browser.
func (s *Server) handleSpeedRelayUp(w http.ResponseWriter, r *http.Request) {
a := s.authenticateAgent(r)
if a == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
rl := s.relays.get(a.ID)
if rl == nil {
http.NotFound(w, r)
return
}
body := http.MaxBytesReader(w, r.Body, speedUpCap)
// A proxy in front may hand over the whole upload at once: pass on only
// what the browser takes in the window, or the agent would wait for the
// rest to trickle through a slower link.
t := time.AfterFunc(speedRelayUpFor, func() { rl.upW.Close() })
_, _ = io.Copy(rl.upW, body)
t.Stop()
rl.upW.Close()
_, _ = io.Copy(io.Discard, body)
w.WriteHeader(http.StatusNoContent)
}
+4 -4
View File
@@ -12,7 +12,7 @@ import (
// same transport the agent uses), so it works through any reverse proxy without // same transport the agent uses), so it works through any reverse proxy without
// WebSocket upgrade support. // WebSocket upgrade support.
// POST /api/panel/term/{agentID}/open — create a terminal session. // POST /api/panel/term/{agentID}/open - create a terminal session.
func (s *Server) handlePanelOpen(w http.ResponseWriter, r *http.Request) { func (s *Server) handlePanelOpen(w http.ResponseWriter, r *http.Request) {
agentID := r.PathValue("agentID") agentID := r.PathValue("agentID")
agent, err := s.db.GetAgent(agentID) agent, err := s.db.GetAgent(agentID)
@@ -28,7 +28,7 @@ func (s *Server) handlePanelOpen(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]string{"session_id": sess.ID}) writeJSON(w, map[string]string{"session_id": sess.ID})
} }
// GET /api/panel/term/{sid}/out — long-poll for PTY output. // GET /api/panel/term/{sid}/out - long-poll for PTY output.
func (s *Server) handlePanelOut(w http.ResponseWriter, r *http.Request) { func (s *Server) handlePanelOut(w http.ResponseWriter, r *http.Request) {
sess := s.hub.Session(r.PathValue("sid")) sess := s.hub.Session(r.PathValue("sid"))
if sess == nil { if sess == nil {
@@ -51,7 +51,7 @@ type panelInMsg struct {
Rows uint16 `json:"rows,omitempty"` Rows uint16 `json:"rows,omitempty"`
} }
// POST /api/panel/term/{sid}/in — send keystrokes/resize to the agent PTY. // POST /api/panel/term/{sid}/in - send keystrokes/resize to the agent PTY.
func (s *Server) handlePanelIn(w http.ResponseWriter, r *http.Request) { func (s *Server) handlePanelIn(w http.ResponseWriter, r *http.Request) {
sess := s.hub.Session(r.PathValue("sid")) sess := s.hub.Session(r.PathValue("sid"))
if sess == nil { if sess == nil {
@@ -72,7 +72,7 @@ func (s *Server) handlePanelIn(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
// POST /api/panel/term/{sid}/close — tear the session down. // POST /api/panel/term/{sid}/close - tear the session down.
func (s *Server) handlePanelClose(w http.ResponseWriter, r *http.Request) { func (s *Server) handlePanelClose(w http.ResponseWriter, r *http.Request) {
s.hub.CloseSession(r.PathValue("sid")) s.hub.CloseSession(r.PathValue("sid"))
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
+63 -4
View File
@@ -22,6 +22,12 @@ type Hub struct {
sessions map[string]*Session sessions map[string]*Session
tasks map[string]*taskEntry tasks map[string]*taskEntry
// Presence: control polls held open right now per agent, and when each
// agent's last one ended. started is when this hub came up.
polls map[string]int
lastPoll map[string]time.Time
started time.Time
} }
// taskEntry is a pending one-shot task awaiting the agent's result. // taskEntry is a pending one-shot task awaiting the agent's result.
@@ -31,7 +37,7 @@ type taskEntry struct {
} }
// Session is one live byte stream between the panel and an agent (a PTY terminal // Session is one live byte stream between the panel and an agent (a PTY terminal
// or a raw TCP forward — the hub treats both identically). // or a raw TCP forward - the hub treats both identically).
type Session struct { type Session struct {
ID string ID string
AgentID string AgentID string
@@ -52,6 +58,9 @@ func NewHub() *Hub {
ctrlWaiters: map[string]chan struct{}{}, ctrlWaiters: map[string]chan struct{}{},
sessions: map[string]*Session{}, sessions: map[string]*Session{},
tasks: map[string]*taskEntry{}, tasks: map[string]*taskEntry{},
polls: map[string]int{},
lastPoll: map[string]time.Time{},
started: time.Now(),
} }
} }
@@ -97,9 +106,56 @@ func (h *Hub) CreateForward(agentID string, port int) *Session {
return s return s
} }
// WaitControl blocks until a job is queued for agentID or timeout elapses. // pollGrace is how long an agent stays online between two control polls: it
func (h *Hub) WaitControl(agentID string, timeout time.Duration) proto.ControlMessage { // re-polls at once, so this only has to cover a slow round trip or one retry.
deadline := time.After(timeout) const pollGrace = 10 * time.Second
// Online reports whether the agent is connected: it holds a control poll open
// or has just finished one. ok is false while that cannot be told yet (right
// after a start, before agents had time to come back).
func (h *Hub) Online(agentID string) (online, ok bool) {
h.mu.Lock()
defer h.mu.Unlock()
if h.polls[agentID] > 0 {
return true, true
}
if t, seen := h.lastPoll[agentID]; seen {
return time.Since(t) < pollGrace, true
}
return false, time.Since(h.started) > pollHold+pollGrace
}
// Forget drops what the hub keeps about a deleted agent.
func (h *Hub) Forget(agentID string) {
h.mu.Lock()
delete(h.jobs, agentID)
delete(h.ctrlWaiters, agentID)
delete(h.lastPoll, agentID)
h.mu.Unlock()
}
// WaitControl blocks until a job is queued for agentID, the timeout elapses
// or the agent hangs up (done). The agent counts as online meanwhile.
func (h *Hub) WaitControl(done <-chan struct{}, agentID string, timeout time.Duration) proto.ControlMessage {
h.mu.Lock()
h.polls[agentID]++
h.mu.Unlock()
defer func() {
h.mu.Lock()
if h.polls[agentID]--; h.polls[agentID] <= 0 {
delete(h.polls, agentID)
}
// A poll cut off by the agent is not a sign of life.
select {
case <-done:
default:
h.lastPoll[agentID] = time.Now()
}
h.mu.Unlock()
}()
t := time.NewTimer(timeout)
defer t.Stop()
deadline := t.C
for { for {
h.mu.Lock() h.mu.Lock()
if q := h.jobs[agentID]; len(q) > 0 { if q := h.jobs[agentID]; len(q) > 0 {
@@ -119,6 +175,9 @@ func (h *Hub) WaitControl(agentID string, timeout time.Duration) proto.ControlMe
case <-w: case <-w:
case <-deadline: case <-deadline:
return proto.ControlMessage{Type: proto.CtrlIdle} return proto.ControlMessage{Type: proto.CtrlIdle}
case <-done:
// The job, if one comes, stays queued for the next poll.
return proto.ControlMessage{Type: proto.CtrlIdle}
} }
} }
} }
+28 -2
View File
@@ -26,8 +26,8 @@ type Options struct {
// nginx TLS); set false only for plain-HTTP local testing. // nginx TLS); set false only for plain-HTTP local testing.
SecureCookies bool SecureCookies bool
// TunnelAddr is the listen address for the direct TCP+UDP forward tunnel // TunnelAddr is the listen address for the direct TCP+UDP forward tunnel
// (default ":8446"). Empty disables direct forwarding. The port can be // Empty disables direct forwarding. The port can be overridden from the
// overridden from the settings page. // settings page.
TunnelAddr string TunnelAddr string
} }
@@ -52,6 +52,10 @@ type Server struct {
tunnelDefault int tunnelDefault int
tunnelMu sync.Mutex // serializes port changes tunnelMu sync.Mutex // serializes port changes
tunnelPort atomic.Int64 tunnelPort atomic.Int64
stats statCache // last load sample per agent, for the status page
relays speedRelays // speed tests that run through the browser
meters speedMeters // byte counts of the speed tests under way
} }
// New builds the server, parsing embedded templates. // New builds the server, parsing embedded templates.
@@ -161,6 +165,12 @@ func (s *Server) restoreForwards() {
return return
} }
for _, r := range rules { for _, r := range rules {
// Rules of an agent deleted by an older version: drop them.
if a, err := s.db.GetAgent(r.AgentID); err == nil && a == nil {
log.Printf("forward rule %d: agent %s is gone, removing", r.ID, r.AgentID)
s.removeForward(r.ID)
continue
}
if err := s.fwd.open(r); err != nil { if err := s.fwd.open(r); err != nil {
log.Printf("restore forward rule %d (%s:%d): %v", r.ID, r.ListenAddr, r.HostPort, err) log.Printf("restore forward rule %d (%s:%d): %v", r.ID, r.ListenAddr, r.HostPort, err)
} }
@@ -194,6 +204,7 @@ func (s *Server) Handler() http.Handler {
mux.Handle("GET /agents/{id}/terminal", s.requireAdmin(s.handleTerminalPage)) mux.Handle("GET /agents/{id}/terminal", s.requireAdmin(s.handleTerminalPage))
mux.Handle("GET /agents/{id}/files", s.requireAdmin(s.handleFilesPage)) mux.Handle("GET /agents/{id}/files", s.requireAdmin(s.handleFilesPage))
mux.Handle("GET /agents/{id}/forwards", s.requireAdmin(s.handleForwardsPage)) mux.Handle("GET /agents/{id}/forwards", s.requireAdmin(s.handleForwardsPage))
mux.Handle("GET /agents/{id}/status", s.requireAdmin(s.handleStatusPage))
mux.Handle("GET /agents/{id}/edit", s.requireAdmin(s.handleAgentEdit)) mux.Handle("GET /agents/{id}/edit", s.requireAdmin(s.handleAgentEdit))
mux.Handle("POST /agents/{id}/rename", s.requireAdmin(s.handleAgentRename)) mux.Handle("POST /agents/{id}/rename", s.requireAdmin(s.handleAgentRename))
mux.Handle("POST /agents/{id}/update", s.requireAdmin(s.handleAgentUpdate)) mux.Handle("POST /agents/{id}/update", s.requireAdmin(s.handleAgentUpdate))
@@ -207,6 +218,7 @@ func (s *Server) Handler() http.Handler {
mux.Handle("POST /settings/admins/add", s.requireAdmin(s.handleAddAdmin)) mux.Handle("POST /settings/admins/add", s.requireAdmin(s.handleAddAdmin))
mux.Handle("POST /settings/admins/delete", s.requireAdmin(s.handleDeleteAdmin)) mux.Handle("POST /settings/admins/delete", s.requireAdmin(s.handleDeleteAdmin))
mux.Handle("POST /settings/ufw/toggle", s.requireAdmin(s.handleUfwToggle)) mux.Handle("POST /settings/ufw/toggle", s.requireAdmin(s.handleUfwToggle))
mux.Handle("POST /settings/ufw/add", s.requireAdmin(s.handleUfwAdd))
mux.Handle("POST /settings/ufw/revoke", s.requireAdmin(s.handleUfwRevoke)) mux.Handle("POST /settings/ufw/revoke", s.requireAdmin(s.handleUfwRevoke))
// Panel terminal over HTTP long-polling (admin-only). // Panel terminal over HTTP long-polling (admin-only).
@@ -219,6 +231,16 @@ func (s *Server) Handler() http.Handler {
mux.Handle("GET /api/panel/agents/{id}/fs/download", s.requireAdmin(s.handleFSDownload)) mux.Handle("GET /api/panel/agents/{id}/fs/download", s.requireAdmin(s.handleFSDownload))
mux.Handle("POST /api/panel/agents/{id}/fs/{op}", s.requireAdmin(s.handleFSOp)) mux.Handle("POST /api/panel/agents/{id}/fs/{op}", s.requireAdmin(s.handleFSOp))
// Machine status (admin).
mux.Handle("GET /api/panel/agents/presence", s.requireAdmin(s.handlePresence))
mux.Handle("GET /api/panel/agents/{id}/stat", s.requireAdmin(s.handleAgentStat))
mux.Handle("POST /api/panel/agents/{id}/speed", s.requireAdmin(s.handleAgentSpeed))
mux.Handle("GET /api/panel/agents/{id}/speed/progress", s.requireAdmin(s.handleSpeedProgress))
mux.Handle("GET /api/panel/ping", s.requireAdmin(s.handlePanelPing))
mux.Handle("POST /api/panel/agents/{id}/speed/relay", s.requireAdmin(s.handleSpeedRelayOpen))
mux.Handle("POST /api/panel/agents/{id}/speed/down", s.requireAdmin(s.handleSpeedRelayFeed))
mux.Handle("GET /api/panel/agents/{id}/speed/up", s.requireAdmin(s.handleSpeedRelayDrain))
// Panel port forwarding (admin-only). // Panel port forwarding (admin-only).
mux.Handle("POST /agents/{id}/forwards/add", s.requireAdmin(s.handleForwardAdd)) mux.Handle("POST /agents/{id}/forwards/add", s.requireAdmin(s.handleForwardAdd))
mux.Handle("POST /forwards/{rid}/delete", s.requireAdmin(s.handleForwardDelete)) mux.Handle("POST /forwards/{rid}/delete", s.requireAdmin(s.handleForwardDelete))
@@ -231,6 +253,10 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("POST /api/agent/term/{sid}/out", s.handleTermOut) mux.HandleFunc("POST /api/agent/term/{sid}/out", s.handleTermOut)
mux.HandleFunc("GET /api/agent/task/{id}", s.handleTaskFetch) mux.HandleFunc("GET /api/agent/task/{id}", s.handleTaskFetch)
mux.HandleFunc("POST /api/agent/task/{id}/result", s.handleTaskResult) mux.HandleFunc("POST /api/agent/task/{id}/result", s.handleTaskResult)
mux.HandleFunc("GET /api/agent/speed/down", s.handleSpeedDown)
mux.HandleFunc("POST /api/agent/speed/up", s.handleSpeedUp)
mux.HandleFunc("GET /api/agent/speed/relay/down", s.handleSpeedRelayDown)
mux.HandleFunc("POST /api/agent/speed/relay/up", s.handleSpeedRelayUp)
return s.logRequests(mux) return s.logRequests(mux)
} }
+2 -2
View File
@@ -11,11 +11,11 @@ import (
"github.com/x-grk/longpolling-remote-access/internal/tunnel" "github.com/x-grk/longpolling-remote-access/internal/tunnel"
) )
// udpRelay carries UDP forwards over the direct 8446 UDP channel. The agent // udpRelay carries UDP forwards over the direct UDP channel. The agent
// registers its UDP return address; end-user datagrams arriving on a rule's // registers its UDP return address; end-user datagrams arriving on a rule's
// host port are relayed to the agent and replies are sent back. // host port are relayed to the agent and replies are sent back.
// //
// Datagram framing on the 8446 control socket: // Datagram framing on the tunnel control socket:
// //
// registration (agent->server): "R <agentID> <sig>" // registration (agent->server): "R <agentID> <sig>"
// data (both): 'D' | sid(4) | port(2) | payload // data (both): 'D' | sid(4) | port(2) | payload
+43 -7
View File
@@ -54,6 +54,16 @@ func (u *ufwManager) status() (installed, active, runnable bool) {
return installed, active, runnable return installed, active, runnable
} }
// enabled reports whether the integration is switched on and ufw is there to
// drive. Unlike available it runs nothing, so pages can ask it freely.
func (u *ufwManager) enabled() bool {
if !u.db.UfwEnabled() {
return false
}
_, err := exec.LookPath("ufw")
return err == nil
}
// available reports whether we can and should drive ufw now. // available reports whether we can and should drive ufw now.
func (u *ufwManager) available() bool { func (u *ufwManager) available() bool {
if !u.db.UfwEnabled() { if !u.db.UfwEnabled() {
@@ -77,26 +87,46 @@ func (u *ufwManager) LastError() string {
} }
// allow opens port/proto in ufw and records it, bound to the given forward // allow opens port/proto in ufw and records it, bound to the given forward
// rule id (0 = shared/manual). The caller has checked available(). // rule id (0 = shared, ufwManual = added in Settings). It reports whether the
func (u *ufwManager) allow(port int, proto string, ruleID int64) { // panel now tracks the rule. The caller has checked available().
func (u *ufwManager) allow(port int, proto string, ruleID int64) bool {
if u.db.HasUfwRule(port, proto) { if u.db.HasUfwRule(port, proto) {
return return true
} }
spec := fmt.Sprintf("%d/%s", port, proto) spec := fmt.Sprintf("%d/%s", port, proto)
out, err := ufwRun("allow", spec) out, err := ufwRun("allow", spec)
if err != nil { if err != nil {
log.Printf("ufw allow %s: %v: %s", spec, err, out) log.Printf("ufw allow %s: %v: %s", spec, err, out)
u.setErr(fmt.Sprintf("ufw allow %s: %v: %s", spec, err, out)) u.setErr(fmt.Sprintf("ufw allow %s: %v: %s", spec, err, out))
return return false
} }
u.setErr("") u.setErr("")
if strings.Contains(out, "Skipping") { if strings.Contains(out, "Skipping") {
// The rule was there before the panel asked for it, so it is not ours // The rule was there before the panel asked for it, so it is not ours
// to track (and later delete). // to track (and later delete).
return return false
} }
_ = u.db.AddUfwRule(port, proto, ruleID) _ = u.db.AddUfwRule(port, proto, ruleID)
log.Printf("ufw: allowed %s", spec) log.Printf("ufw: allowed %s", spec)
return true
}
// allowManual opens a port the admin asked for in Settings. A port the panel
// already opened for a forward or the tunnel becomes the admin's, so it stays
// when they no longer need it. It returns the error key for the settings page
// ("" on success).
func (u *ufwManager) allowManual(port int, proto string) string {
if !u.available() {
return "ufw_off"
}
if !u.allow(port, proto, ufwManual) {
if u.LastError() != "" {
return "ufw_failed"
}
return "ufw_exists"
}
_ = u.db.SetUfwRuleManual(port, proto)
return ""
} }
// revokeRule deletes one tracked ufw rule from ufw and the tracking table. // revokeRule deletes one tracked ufw rule from ufw and the tracking table.
@@ -148,7 +178,9 @@ func (u *ufwManager) pruneShared() {
} }
used := map[string]bool{} used := map[string]bool{}
for _, f := range forwards { for _, f := range forwards {
used[f.Proto] = true if !f.NoUfw {
used[f.Proto] = true
}
} }
port := u.tunnelPort() port := u.tunnelPort()
for _, r := range shared { for _, r := range shared {
@@ -160,7 +192,8 @@ func (u *ufwManager) pruneShared() {
// allowForward opens the host ports a forward rule needs: its host port (bound // allowForward opens the host ports a forward rule needs: its host port (bound
// to the rule so it is revoked with it) plus the shared tunnel port for direct // to the rule so it is revoked with it) plus the shared tunnel port for direct
// tcp/udp modes (bound to 0 — shared across rules). // tcp/udp modes (bound to 0 - shared across rules). A rule made "without ufw"
// opens nothing, so nothing is revoked with it either.
func (u *ufwManager) allowForward(rule ForwardRule) { func (u *ufwManager) allowForward(rule ForwardRule) {
if !u.available() { if !u.available() {
return return
@@ -169,6 +202,9 @@ func (u *ufwManager) allowForward(rule ForwardRule) {
} }
func (u *ufwManager) allowForwardPorts(rule ForwardRule) { func (u *ufwManager) allowForwardPorts(rule ForwardRule) {
if rule.NoUfw {
return
}
// "http" (long-poll) and "tcp" forwards both listen on TCP on the host. // "http" (long-poll) and "tcp" forwards both listen on TCP on the host.
hostProto, direct := "tcp", rule.Proto == "tcp" hostProto, direct := "tcp", rule.Proto == "tcp"
if rule.Proto == "udp" { if rule.Proto == "udp" {
+43 -3
View File
@@ -10,6 +10,8 @@
--ok: #37c871; --ok: #37c871;
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
/* The hidden attribute wins over any display set by a class. */
[hidden] { display: none !important; }
body { body {
margin: 0; margin: 0;
background: var(--bg); background: var(--bg);
@@ -63,10 +65,24 @@ button:hover, .btn:hover { background: var(--accent-hover); text-decoration: non
.btn.small { padding: 6px 10px; font-size: 13px; } .btn.small { padding: 6px 10px; font-size: 13px; }
.btn.icon { width: 30px; height: 30px; padding: 0; display: inline-flex; .btn.icon { width: 30px; height: 30px; padding: 0; display: inline-flex;
align-items: center; justify-content: center; font-size: 14px; line-height: 1; text-decoration: none; } align-items: center; justify-content: center; font-size: 14px; line-height: 1; text-decoration: none; }
/* Name of an icon button, shown at once on hover or keyboard focus. */
[data-tip] { position: relative; }
[data-tip]::after {
content: attr(data-tip); position: absolute; bottom: calc(100% + 6px); left: 50%;
transform: translateX(-50%); padding: 4px 8px; border-radius: 6px; white-space: nowrap;
background: #0c0f13; border: 1px solid var(--border); color: var(--text);
font-size: 12px; font-weight: 400; line-height: 1.4;
opacity: 0; visibility: hidden; pointer-events: none; z-index: 10;
}
[data-tip]:hover::after, [data-tip]:focus-visible::after { opacity: 1; visibility: visible; }
.err { background: #3a1d1d; border: 1px solid #6b2b2b; color: #ffb3b3; padding: 10px 12px; border-radius: 8px; margin-bottom: 14px; font-size: 14px; } .err { background: #3a1d1d; border: 1px solid #6b2b2b; color: #ffb3b3; padding: 10px 12px; border-radius: 8px; margin-bottom: 14px; font-size: 14px; }
table { width: 100%; border-collapse: collapse; } table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--border); font-size: 14px; } th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--border); font-size: 14px; }
th { color: var(--muted); font-weight: 500; } th { color: var(--muted); font-weight: 500; }
.nowrap { white-space: nowrap; }
/* Row buttons stay on one line; the column is only as wide as they need. */
td.actions { width: 1%; white-space: nowrap; }
td.actions .row { flex-wrap: nowrap; justify-content: flex-end; }
.dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; margin-right: 6px; } .dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; margin-right: 6px; }
.dot.on { background: var(--ok); } .dot.on { background: var(--ok); }
.dot.off { background: #555d6a; } .dot.off { background: #555d6a; }
@@ -80,7 +96,31 @@ pre { padding: 12px; overflow-x: auto; font-size: 13px; white-space: pre-wrap; w
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; } .row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.spacer { flex: 1; } .spacer { flex: 1; }
#terminal { width: 100%; height: calc(100vh - 150px); } #terminal { width: 100%; height: calc(100vh - 150px); }
@media (max-width: 600px) { /* Phones. A table marked .stack turns each row into a block of
header.topbar { padding: 12px 16px; } "label: value" lines, the labels coming from data-l on the cells; a cell
header.topbar nav a { margin-left: 12px; } marked .lead goes first, as the row's title. */
@media (max-width: 640px) {
header.topbar { flex-wrap: wrap; gap: 6px 12px; padding: 10px 12px; }
header.topbar .brand { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; }
header.topbar nav { display: flex; align-items: center; gap: 16px; max-width: 100%;
overflow-x: auto; white-space: nowrap; scrollbar-width: none; }
header.topbar nav::-webkit-scrollbar { display: none; }
header.topbar nav > * { margin-left: 0 !important; flex: none; }
.wrap { padding: 16px 12px; }
.card { padding: 16px; border-radius: 8px; }
h1 { font-size: 20px; }
code { word-break: break-all; }
/* 16px and up, or iOS zooms the page into a field on focus. */
input[type=text], input[type=password], input[type=number], select, textarea { font-size: 16px; }
table.stack thead { display: none; }
table.stack, table.stack tbody { display: block; }
table.stack tr { display: flex; flex-direction: column; gap: 2px; padding: 12px 0; border-bottom: 1px solid var(--border); }
table.stack tr:first-child { padding-top: 0; }
table.stack tr:last-child { border-bottom: 0; padding-bottom: 0; }
table.stack td { display: flex; justify-content: flex-start; align-items: center; gap: 0;
width: auto; padding: 2px 0; border: 0; text-align: right !important; overflow-wrap: anywhere; }
table.stack td[data-l]::before { content: attr(data-l); flex: none; margin-right: auto; padding-right: 12px; color: var(--muted); font-size: 13px; text-align: left; }
table.stack td.lead { order: -1; justify-content: flex-start; text-align: left !important; font-size: 16px; font-weight: 600; }
table.stack td.actions { width: auto; padding-top: 8px; justify-content: flex-start; }
table.stack td:empty { display: none; }
} }
+59 -17
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Машины — LPRA</title> <title>Машины - LPRA</title>
<link rel="stylesheet" href="/static/app.css"> <link rel="stylesheet" href="/static/app.css">
</head> </head>
<body> <body>
@@ -21,28 +21,37 @@
<h1>Машины-клиенты</h1> <h1>Машины-клиенты</h1>
<div class="card"> <div class="card">
{{if .Agents}} {{if .Agents}}
<table> <table class="stack">
<thead><tr><th>Статус</th><th>Имя</th><th>Hostname</th><th>ОС / Арх</th><th>Активность</th><th></th></tr></thead> <thead><tr><th>Статус</th><th>Имя</th><th>Hostname</th><th>ОС / Арх</th><th>Активность</th><th>Действия</th></tr></thead>
<tbody> <tbody>
{{range .Agents}} {{range .Agents}}
<tr> <tr data-agent="{{.ID}}">
<td><span class="dot {{if .Online}}on{{else}}off{{end}}"></span>{{if .Online}}online{{else}}offline{{end}}</td> <td class="nowrap" data-l="Статус"><span class="dot {{if .Online}}on{{else}}off{{end}}"></span><span class="state">{{if .Online}}online{{else}}offline{{end}}</span></td>
<td>{{.Name}}</td> <td class="lead">{{.Name}}</td>
<td class="muted">{{.Hostname}}</td> <td class="muted" data-l="Hostname">{{.Hostname}}</td>
<td class="muted">{{.OS}}/{{.Arch}}</td> <td class="muted nowrap" data-l="ОС / Арх">{{.OS}}/{{.Arch}}</td>
<td class="muted">{{.Seen}}</td> <td class="muted nowrap seen" data-l="Активность">{{.Seen}}</td>
<td> <td class="actions">
<div class="row" style="gap:6px"> <div class="row" style="gap:6px">
<a class="btn small" href="/agents/{{.ID}}/terminal">Терминал</a> <a class="btn small icon" href="/agents/{{.ID}}/terminal" data-tip="Терминал" aria-label="Терминал">
<a class="btn ghost small" href="/agents/{{.ID}}/files">Файлы</a> <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>
<a class="btn ghost small" href="/agents/{{.ID}}/forwards">Порты</a> </a>
<a class="btn ghost small icon" href="/agents/{{.ID}}/edit" title="Изменить"> <a class="btn ghost small icon" href="/agents/{{.ID}}/files" data-tip="Файлы" aria-label="Файлы">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
</a>
<a class="btn ghost small icon" href="/agents/{{.ID}}/forwards" data-tip="Порты" aria-label="Порты">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>
</a>
<a class="btn ghost small icon" href="/agents/{{.ID}}/status" data-tip="Статус" aria-label="Статус">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
</a>
<a class="btn ghost small icon" href="/agents/{{.ID}}/edit" data-tip="Изменить" aria-label="Изменить">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z"/></svg> <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z"/></svg>
</a> </a>
<form method="post" action="/agents/{{.ID}}/delete" style="display:inline" <form method="post" action="/agents/{{.ID}}/delete" style="display:inline"
onsubmit="return confirm('Удалить машину {{.Name}}? Сервис на ней продолжит работать, но перестанет быть виден. Чтобы снова подключиться — переустановите агента.')"> onsubmit="return confirm('Удалить машину {{.Name}}? Сервис на ней продолжит работать, но перестанет быть виден. Чтобы снова подключиться - переустановите агента.')">
<input type="hidden" name="csrf" value="{{$.CSRF}}"> <input type="hidden" name="csrf" value="{{$.CSRF}}">
<button class="btn danger small icon" type="submit" title="Удалить"> <button class="btn danger small icon" type="submit" data-tip="Удалить" aria-label="Удалить">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg> <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>
</button> </button>
</form> </form>
@@ -54,9 +63,42 @@
</table> </table>
{{else}} {{else}}
<p class="muted">Пока нет подключённых машин. Добавьте первую в разделе <p class="muted">Пока нет подключённых машин. Добавьте первую в разделе
<a href="/settings">Настройки</a> — там одна команда для установки.</p> <a href="/settings">Настройки</a> - там одна команда для установки.</p>
{{end}} {{end}}
</div> </div>
</div> </div>
<script>
// Keep the status column live; nothing is asked while the tab is hidden.
(function () {
var rows = {};
document.querySelectorAll('tr[data-agent]').forEach(function (tr) { rows[tr.dataset.agent] = tr; });
var timer = null, busy = false;
function schedule() {
clearTimeout(timer);
if (!document.hidden) timer = setTimeout(load, 5000);
}
function load() {
if (busy || document.hidden) return;
busy = true;
fetch('/api/panel/agents/presence', {credentials: 'same-origin'})
.then(function (r) { return r.ok ? r.json() : []; })
.then(function (list) {
list.forEach(function (a) {
var tr = rows[a.id];
if (!tr) return;
tr.querySelector('.dot').className = 'dot ' + (a.online ? 'on' : 'off');
tr.querySelector('.state').textContent = a.online ? 'online' : 'offline';
tr.querySelector('.seen').textContent = a.seen;
});
})
.catch(function () {})
.then(function () { busy = false; schedule(); });
}
document.addEventListener('visibilitychange', function () {
if (document.hidden) clearTimeout(timer); else load();
});
if (Object.keys(rows).length) schedule();
})();
</script>
</body> </body>
</html> </html>
+8 -4
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Изменить машину — LPRA</title> <title>Изменить машину - LPRA</title>
<link rel="stylesheet" href="/static/app.css?v={{.ServerVersion}}-2"> <link rel="stylesheet" href="/static/app.css?v={{.ServerVersion}}-2">
</head> </head>
<body> <body>
@@ -11,6 +11,10 @@
<div class="brand">LPRA · Изменить машину</div> <div class="brand">LPRA · Изменить машину</div>
<nav> <nav>
<a href="/">← Машины</a> <a href="/">← Машины</a>
<a href="/agents/{{.Agent.ID}}/terminal">Терминал</a>
<a href="/agents/{{.Agent.ID}}/files">Файлы</a>
<a href="/agents/{{.Agent.ID}}/forwards">Порты</a>
<a href="/agents/{{.Agent.ID}}/status">Статус</a>
</nav> </nav>
</header> </header>
<div class="wrap"> <div class="wrap">
@@ -55,7 +59,7 @@
<label style="color:var(--text); font-size:14px"> <label style="color:var(--text); font-size:14px">
<input type="checkbox" name="sudo" value="1"> Добавить в sudo <input type="checkbox" name="sudo" value="1"> Добавить в sudo
</label> </label>
<p class="muted" style="font-size:13px">Без пароля вход по паролю (и sudo) для него работать не будет — только терминал из панели.</p> <p class="muted" style="font-size:13px">Без пароля вход по паролю (и sudo) для него работать не будет - только терминал из панели.</p>
<button type="submit">Создать пользователя</button> <button type="submit">Создать пользователя</button>
</form> </form>
{{else}} {{else}}
@@ -64,7 +68,7 @@
{{else if .UsersErr}} {{else if .UsersErr}}
<p class="muted" style="font-size:13px">Список пользователей недоступен: {{.UsersErr}}.</p> <p class="muted" style="font-size:13px">Список пользователей недоступен: {{.UsersErr}}.</p>
{{else}} {{else}}
<p class="muted" style="font-size:13px">Машина офлайн — сменить пользователя можно, когда она на связи.</p> <p class="muted" style="font-size:13px">Машина офлайн - сменить пользователя можно, когда она на связи.</p>
{{end}} {{end}}
</div> </div>
@@ -90,7 +94,7 @@
<div class="card"> <div class="card">
<h2>Удаление</h2> <h2>Удаление</h2>
<p class="muted">Удаляет машину из панели. Сервис на самой машине продолжит работать, <p class="muted">Удаляет машину из панели. Сервис на самой машине продолжит работать,
но перестанет быть виден; чтобы подключиться снова — переустановите агента.</p> но перестанет быть виден; чтобы подключиться снова - переустановите агента.</p>
<form method="post" action="/agents/{{.Agent.ID}}/delete" <form method="post" action="/agents/{{.Agent.ID}}/delete"
onsubmit="return confirm('Удалить машину {{.Agent.Name}}?')"> onsubmit="return confirm('Удалить машину {{.Agent.Name}}?')">
<input type="hidden" name="csrf" value="{{.CSRF}}"> <input type="hidden" name="csrf" value="{{.CSRF}}">
+32 -8
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Файлы · {{.Agent.Name}} — LPRA</title> <title>Файлы · {{.Agent.Name}} - LPRA</title>
<link rel="stylesheet" href="/static/app.css"> <link rel="stylesheet" href="/static/app.css">
<style> <style>
.fm-bar { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-bottom:12px; } .fm-bar { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-bottom:12px; }
@@ -27,6 +27,20 @@
.ic { display:inline-block; width:18px; text-align:center; margin-right:6px; } .ic { display:inline-block; width:18px; text-align:center; margin-right:6px; }
.sz { color:var(--muted); font-variant-numeric:tabular-nums; } .sz { color:var(--muted); font-variant-numeric:tabular-nums; }
.act { display:flex; gap:6px; justify-content:flex-end; } .act { display:flex; gap:6px; justify-content:flex-end; }
table.fm .c-sz { width:120px; }
table.fm .c-mt { width:160px; }
table.fm .c-act { width:160px; }
.fm .nm { overflow-wrap:anywhere; }
/* Phones: no date column, and the row buttons only as wide as they are. */
@media (max-width: 640px) {
table.fm .c-mt, table.fm td.mt { display:none; }
table.fm .c-sz { width:auto; white-space:nowrap; }
table.fm .c-act { width:1%; }
table.fm td, table.fm th { padding:7px 4px; }
.ic { margin-right:4px; }
.fm-bar input.fm-path { flex:1 1 100%; order:-1; }
textarea#edit { font-size:13px; }
}
dialog { background:var(--panel); color:var(--text); border:1px solid var(--border); dialog { background:var(--panel); color:var(--text); border:1px solid var(--border);
border-radius:12px; padding:0; max-width:900px; width:92vw; } border-radius:12px; padding:0; max-width:900px; width:92vw; }
dialog::backdrop { background:rgba(0,0,0,.6); } dialog::backdrop { background:rgba(0,0,0,.6); }
@@ -44,11 +58,13 @@
</head> </head>
<body> <body>
<header class="topbar"> <header class="topbar">
<div class="brand">LPRA · Файлы — {{.Agent.Name}} <span class="muted">({{.Agent.Hostname}})</span></div> <div class="brand">LPRA · Файлы - {{.Agent.Name}} <span class="muted">({{.Agent.Hostname}})</span></div>
<nav> <nav>
<a href="/">← Машины</a> <a href="/">← Машины</a>
<a href="/agents/{{.Agent.ID}}/terminal">Терминал</a> <a href="/agents/{{.Agent.ID}}/terminal">Терминал</a>
<a href="/agents/{{.Agent.ID}}/forwards">Порты</a> <a href="/agents/{{.Agent.ID}}/forwards">Порты</a>
<a href="/agents/{{.Agent.ID}}/status">Статус</a>
<a href="/agents/{{.Agent.ID}}/edit">Изменить</a>
</nav> </nav>
</header> </header>
<div class="wrap" style="max-width:1100px"> <div class="wrap" style="max-width:1100px">
@@ -72,13 +88,13 @@
<table class="fm"> <table class="fm">
<thead><tr> <thead><tr>
<th style="width:28px"><input type="checkbox" id="all"></th> <th style="width:28px"><input type="checkbox" id="all"></th>
<th>Имя</th><th style="width:120px" class="sz">Размер</th> <th>Имя</th><th class="sz c-sz">Размер</th>
<th style="width:160px" class="sz">Изменён</th><th style="width:160px"></th> <th class="sz c-mt">Изменён</th><th class="c-act"></th>
</tr></thead> </tr></thead>
<tbody id="list"></tbody> <tbody id="list"></tbody>
</table> </table>
</div> </div>
<p class="muted" id="hint" style="font-size:13px">Перетащите файлы сюда для загрузки. Клик по файлу — просмотр (правка, если &lt; 2 КБ).</p> <p class="muted" id="hint" style="font-size:13px">Перетащите файлы сюда для загрузки. Клик по файлу - просмотр и правка.</p>
</div> </div>
<dialog id="viewer"> <dialog id="viewer">
@@ -154,8 +170,8 @@
tr.innerHTML = tr.innerHTML =
'<td><input type="checkbox" class="ck" data-n="'+encodeURIComponent(e.name)+'"></td>' + '<td><input type="checkbox" class="ck" data-n="'+encodeURIComponent(e.name)+'"></td>' +
'<td class="nm"><span class="ic">'+(e.is_dir?'📁':'📄')+'</span>'+esc(e.name)+'</td>' + '<td class="nm"><span class="ic">'+(e.is_dir?'📁':'📄')+'</span>'+esc(e.name)+'</td>' +
'<td class="sz">'+(e.is_dir?'':fmtSize(e.size))+'</td>' + '<td class="sz c-sz">'+(e.is_dir?'':fmtSize(e.size))+'</td>' +
'<td class="sz">'+fmtTime(e.mtime)+'</td>' + '<td class="sz mt">'+fmtTime(e.mtime)+'</td>' +
'<td><div class="act">' + '<td><div class="act">' +
(isZip?'<button class="btn ghost small" data-act="unzip">unzip</button>':'') + (isZip?'<button class="btn ghost small" data-act="unzip">unzip</button>':'') +
(e.is_dir?'':'<a class="btn ghost small icon" title="Скачать" href="'+dlHref+'" download>'+ICON_DL+'</a>') + (e.is_dir?'':'<a class="btn ghost small icon" title="Скачать" href="'+dlHref+'" download>'+ICON_DL+'</a>') +
@@ -280,7 +296,15 @@
['dragleave','drop'].forEach(x => drop.addEventListener(x, e => { e.preventDefault(); if(x!=='drop')drop.classList.remove('drag'); })); ['dragleave','drop'].forEach(x => drop.addEventListener(x, e => { e.preventDefault(); if(x!=='drop')drop.classList.remove('drag'); }));
drop.addEventListener('drop', e => { drop.classList.remove('drag'); if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files); }); drop.addEventListener('drop', e => { drop.classList.remove('drag'); if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files); });
load('/'); // Start in the home directory of the machine's user; an agent too old to
// know "~" fails that listing, so fall back to the root.
(async () => {
const r = await api('list', {path: '~' + {{.Agent.LoginUser}}});
if (!r.ok) { load('/'); return; }
cwd = r.data.path; entries = r.data.entries || []; selected.clear();
$('path').value = cwd;
render();
})();
})(); })();
</script> </script>
</body> </body>
+63 -32
View File
@@ -3,49 +3,69 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Порты · {{.Agent.Name}} — LPRA</title> <title>Порты · {{.Agent.Name}} - LPRA</title>
<link rel="stylesheet" href="/static/app.css"> <link rel="stylesheet" href="/static/app.css">
<style> <style>
.frow { display:flex; align-items:flex-end; gap:14px; flex-wrap:wrap; } /* One line: fixed narrow fields, the label takes what is left. */
.frow label { margin:0 0 6px; } .frow { display:flex; align-items:flex-end; gap:12px; flex-wrap:wrap; }
.frow .fld { display:flex; flex-direction:column; } .frow label { margin:0 0 6px; white-space:nowrap; }
.frow select, .frow input, .frow button { .frow .fld { display:flex; flex-direction:column; flex:none; width:108px; }
.frow .fld.addr { width:128px; }
.frow .fld.grow { flex:1; width:auto; min-width:150px; }
.frow select, .frow input[type=text], .frow button {
height:40px; box-sizing:border-box; font-size:14px; line-height:1; margin:0; height:40px; box-sizing:border-box; font-size:14px; line-height:1; margin:0;
} }
.frow select { .frow select { min-width:0; width:100%; padding:0 30px 0 12px; background-position:right 10px center; }
padding:0 32px 0 12px; border-radius:8px; border:1px solid var(--border); /* The ufw switch hangs under the button, centred on it, level with the note. */
background:#0c0f13; color:var(--text); .frow .act { position:relative; }
.frow .act label.check {
position:absolute; top:100%; left:50%; transform:translateX(-50%);
display:inline-flex; align-items:center; gap:8px; height:20px; margin:12px 0 0;
color:var(--text); font-size:14px; cursor:pointer;
}
.under { min-height:20px; margin:12px 110px 0 0; font-size:13px; line-height:20px; }
/* Phones: two fields a line, the label and the button each a line of their own. */
@media (max-width: 640px) {
.frow .fld, .frow .fld.addr { width:calc(50% - 6px); }
.frow .fld.grow { flex:1 1 100%; }
.frow .act { display:flex; align-items:center; gap:16px; width:100%; }
.frow .act button { flex:1; }
.frow .act label.check { position:static; transform:none; margin:0; }
.under { margin-right:0; }
} }
</style> </style>
</head> </head>
<body> <body>
<header class="topbar"> <header class="topbar">
<div class="brand">LPRA · Порты — {{.Agent.Name}} <span class="muted">({{.Agent.Hostname}})</span></div> <div class="brand">LPRA · Порты - {{.Agent.Name}} <span class="muted">({{.Agent.Hostname}})</span></div>
<nav> <nav>
<a href="/">← Машины</a> <a href="/">← Машины</a>
<a href="/agents/{{.Agent.ID}}/terminal">Терминал</a> <a href="/agents/{{.Agent.ID}}/terminal">Терминал</a>
<a href="/agents/{{.Agent.ID}}/files">Файлы</a> <a href="/agents/{{.Agent.ID}}/files">Файлы</a>
<a href="/agents/{{.Agent.ID}}/status">Статус</a>
<a href="/agents/{{.Agent.ID}}/edit">Изменить</a>
</nav> </nav>
</header> </header>
<div class="wrap"> <div class="wrap">
<h1>Проброс портов</h1> <h1>Проброс портов</h1>
<p class="muted">Порт сервиса на машине-клиенте становится доступен на порту хоста. <p class="muted">Порт сервиса на машине-клиенте становится доступен на порту хоста.
Клиент за NAT — трафик идёт исходящим подключением агента.</p> Клиент за NAT - трафик идёт исходящим подключением агента.</p>
{{if .Error}}<div class="err">{{.Error}}</div>{{end}} {{if .Error}}<div class="err">{{.Error}}</div>{{end}}
<div class="card"> <div class="card">
<h2>Правила</h2> <h2>Правила</h2>
{{if .Rules}} {{if .Rules}}
<table> <table class="stack">
<thead><tr><th>Тип</th><th>Хост (порт)</th><th>→ Клиент (порт)</th><th>Слушать на</th><th></th></tr></thead> <thead><tr><th>Тип</th><th>Хост (порт)</th><th>→ Клиент (порт)</th><th>Слушать на</th><th>Метка</th><th></th></tr></thead>
<tbody> <tbody>
{{range .Rules}} {{range .Rules}}
<tr> <tr>
<td><code>{{.Proto}}</code></td> <td data-l="Тип"><code>{{.Proto}}</code></td>
<td class="muted">:{{.HostPort}}</td> <td class="muted" data-l="Хост">:{{.HostPort}}</td>
<td class="muted">127.0.0.1:{{.ClientPort}}</td> <td class="muted" data-l="→ Клиент">127.0.0.1:{{.ClientPort}}</td>
<td class="muted">{{.ListenAddr}}</td> <td class="muted" data-l="Слушать на">{{.ListenAddr}}{{if and .NoUfw $.UfwEnabled}} · без ufw{{end}}</td>
<td style="text-align:right"> <td class="lead">{{.Label}}</td>
<td class="actions" style="text-align:right">
<form method="post" action="/forwards/{{.ID}}/delete" style="display:inline" <form method="post" action="/forwards/{{.ID}}/delete" style="display:inline"
onsubmit="return confirm('Удалить правило?')"> onsubmit="return confirm('Удалить правило?')">
<input type="hidden" name="csrf" value="{{$.CSRF}}"> <input type="hidden" name="csrf" value="{{$.CSRF}}">
@@ -69,35 +89,46 @@
<div class="fld"> <div class="fld">
<label>Тип</label> <label>Тип</label>
<select name="proto" id="proto"> <select name="proto" id="proto">
<option value="http">http (long-poll, работает везде)</option> <option value="http">http-lp</option>
<option value="tcp">tcp (прямой, низкая задержка, порт {{.TunnelPort}})</option> <option value="tcp">tcp</option>
<option value="udp">udp (прямой, порт {{.TunnelPort}})</option> <option value="udp">udp</option>
</select> </select>
</div> </div>
<div class="fld"> <div class="fld">
<label>Порт на клиенте</label> <label>Порт клиента</label>
<input type="text" name="client_port" placeholder="напр. 22" style="max-width:140px" required> <input type="text" name="client_port" placeholder="22" required>
</div> </div>
<div class="fld"> <div class="fld">
<label>Порт на хосте</label> <label>Порт хоста</label>
<input type="text" name="host_port" placeholder="напр. 2222" style="max-width:140px" required> <input type="text" name="host_port" placeholder="2222" required>
</div> </div>
<div class="fld"> <div class="fld addr">
<label>Слушать на хосте</label> <label>Слушать на</label>
<input type="text" name="listen_addr" value="0.0.0.0" style="max-width:160px"> <input type="text" name="listen_addr" value="0.0.0.0">
</div>
<div class="fld grow">
<label>Метка</label>
<input type="text" name="label" maxlength="80" placeholder="необязательно">
</div>
<div class="act">
<button type="submit">Создать</button>
{{if .UfwEnabled}}
<label class="check" title="Порт не будет открыт в ufw, и при удалении правила в ufw ничего не изменится">
<input type="checkbox" name="no_ufw" value="1"> Без ufw
</label>
{{end}}
</div> </div>
<button type="submit">Создать</button>
</div> </div>
<div class="under"><span class="muted" id="note"></span></div>
</form> </form>
<p class="muted" id="note" style="font-size:13px; margin-top:12px"></p>
</div> </div>
<div class="card"> <div class="card">
<h2>Режимы</h2> <h2>Режимы</h2>
<ul class="muted" style="font-size:14px; line-height:1.7"> <ul class="muted" style="font-size:14px; line-height:1.7">
<li><b>http</b> — туннель поверх HTTP long-poll (через адрес панели, порт {{.PanelPort}}). Работает везде, где доступна панель, но задержка выше.</li> <li><b>http-lp</b> - туннель поверх HTTP long-poll (через адрес панели, порт {{.PanelPort}}). Работает везде, где доступна панель, но задержка выше.</li>
<li><b>tcp</b> — прямой TCP-мультиплексор через порт <b>{{.TunnelPort}}</b> хоста. Минимальная задержка, keep-alive, TCP_NODELAY. Нужно открыть {{.TunnelPort}}/tcp на хосте.</li> <li><b>tcp</b> - прямой TCP-мультиплексор через порт <b>{{.TunnelPort}}</b> хоста. Минимальная задержка, keep-alive, TCP_NODELAY. Нужно открыть {{.TunnelPort}}/tcp на хосте.</li>
<li><b>udp</b> — прямой UDP через порт <b>{{.TunnelPort}}</b> хоста. Нужно открыть {{.TunnelPort}}/udp.</li> <li><b>udp</b> - прямой UDP через порт <b>{{.TunnelPort}}</b> хоста. Нужно открыть {{.TunnelPort}}/udp.</li>
</ul> </ul>
<p class="muted" style="font-size:13px">Для tcp/udp агент держит исходящее соединение к <code>host:{{.TunnelPort}}</code>; несколько клиентов и портов мультиплексируются на нём.</p> <p class="muted" style="font-size:13px">Для tcp/udp агент держит исходящее соединение к <code>host:{{.TunnelPort}}</code>; несколько клиентов и портов мультиплексируются на нём.</p>
</div> </div>
+1 -1
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Вход — LPRA</title> <title>Вход - LPRA</title>
<link rel="stylesheet" href="/static/app.css"> <link rel="stylesheet" href="/static/app.css">
</head> </head>
<body> <body>
+19 -4
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Настройки — LPRA</title> <title>Настройки - LPRA</title>
<link rel="stylesheet" href="/static/app.css"> <link rel="stylesheet" href="/static/app.css">
</head> </head>
<body> <body>
@@ -56,7 +56,7 @@
<p class="muted">Прямой туннель отключён при запуске сервера (пустой <code>-tunnel</code> / <code>LPRA_TUNNEL_ADDR</code>).</p> <p class="muted">Прямой туннель отключён при запуске сервера (пустой <code>-tunnel</code> / <code>LPRA_TUNNEL_ADDR</code>).</p>
{{else}} {{else}}
<p class="muted">Адрес, к которому агенты подключаются для проброса в режимах tcp/udp. Агенты забирают его <p class="muted">Адрес, к которому агенты подключаются для проброса в режимах tcp/udp. Агенты забирают его
с панели сами — при подключении и при каждом опросе, так что смена применяется без переустановки. с панели сами - при подключении и при каждом опросе, так что смена применяется без переустановки.
Хост можно оставить пустым (тогда берётся хост из адреса панели) или указать IP/другое имя.</p> Хост можно оставить пустым (тогда берётся хост из адреса панели) или указать IP/другое имя.</p>
<form method="post" action="/settings/tunnel" class="row"> <form method="post" action="/settings/tunnel" class="row">
<input type="hidden" name="csrf" value="{{.CSRF}}"> <input type="hidden" name="csrf" value="{{.CSRF}}">
@@ -115,7 +115,7 @@
<div class="card"> <div class="card">
<h2>Firewall (ufw)</h2> <h2>Firewall (ufw)</h2>
{{if not .UfwInstalled}} {{if not .UfwInstalled}}
<p class="muted">ufw не установлен на хосте — интеграция недоступна.</p> <p class="muted">ufw не установлен на хосте - интеграция недоступна.</p>
{{else if not .UfwRunnable}} {{else if not .UfwRunnable}}
<p class="muted">ufw установлен, но сервер не может им управлять (нужны права root). <p class="muted">ufw установлен, но сервер не может им управлять (нужны права root).
Запустите сервер под root или настройте sudo для ufw.</p> Запустите сервер под root или настройте sudo для ufw.</p>
@@ -124,7 +124,7 @@
Статус ufw: {{if .UfwActive}}<span style="color:var(--ok)">активен</span>{{else}}<span style="color:var(--danger)">выключен</span>{{end}} Статус ufw: {{if .UfwActive}}<span style="color:var(--ok)">активен</span>{{else}}<span style="color:var(--danger)">выключен</span>{{end}}
· Интеграция: {{if .UfwEnabled}}<span style="color:var(--ok)">включена</span>{{else}}<span class="muted">выключена</span>{{end}} · Интеграция: {{if .UfwEnabled}}<span style="color:var(--ok)">включена</span>{{else}}<span class="muted">выключена</span>{{end}}
</p> </p>
<p class="muted" style="font-size:13px">При создании правила проброса порт открывается в ufw автоматически (для tcp/udp — также порт туннеля{{if .TunnelPort}} {{.TunnelPort}}{{end}}); для http-проброса открывается tcp-порт хоста. Порты правил, слушающих только 127.0.0.1, не открываются.</p> <p class="muted" style="font-size:13px">При создании правила проброса порт открывается в ufw автоматически (для tcp/udp - также порт туннеля{{if .TunnelPort}} {{.TunnelPort}}{{end}}); для http-проброса открывается tcp-порт хоста. Порты правил, слушающих только 127.0.0.1, не открываются.</p>
{{if .UfwError}}<div class="err">Последняя команда ufw завершилась ошибкой: <code>{{.UfwError}}</code></div>{{end}} {{if .UfwError}}<div class="err">Последняя команда ufw завершилась ошибкой: <code>{{.UfwError}}</code></div>{{end}}
<form method="post" action="/settings/ufw/toggle" style="display:inline"> <form method="post" action="/settings/ufw/toggle" style="display:inline">
<input type="hidden" name="csrf" value="{{.CSRF}}"> <input type="hidden" name="csrf" value="{{.CSRF}}">
@@ -141,6 +141,7 @@
{{range .UfwRules}} {{range .UfwRules}}
<tr> <tr>
<td><code>{{.Port}}/{{.Proto}}</code></td> <td><code>{{.Port}}/{{.Proto}}</code></td>
<td class="muted">{{if .Manual}}вручную{{else if .RuleID}}проброс{{else}}туннель{{end}}</td>
<td style="text-align:right"> <td style="text-align:right">
<form method="post" action="/settings/ufw/revoke" style="display:inline" <form method="post" action="/settings/ufw/revoke" style="display:inline"
onsubmit="return confirm('Отменить правило ufw {{.Port}}/{{.Proto}}?')"> onsubmit="return confirm('Отменить правило ufw {{.Port}}/{{.Proto}}?')">
@@ -156,6 +157,20 @@
{{else}} {{else}}
<p class="muted" style="font-size:13px; margin-top:10px">Панель пока не открывала портов в ufw.</p> <p class="muted" style="font-size:13px; margin-top:10px">Панель пока не открывала портов в ufw.</p>
{{end}} {{end}}
{{if and .UfwActive .UfwEnabled}}
<h2 style="margin-top:18px; font-size:15px">Открыть порт вручную</h2>
<form method="post" action="/settings/ufw/add" class="row">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<input type="number" name="port" min="1" max="65535" placeholder="порт" style="max-width:120px" required>
<select name="proto" style="max-width:100px">
<option value="tcp">tcp</option>
<option value="udp">udp</option>
</select>
<button type="submit">Открыть</button>
</form>
<p class="muted" style="font-size:13px">Такое правило не привязано к пробросу и остаётся, пока его не отменить здесь.</p>
{{end}}
{{end}} {{end}}
</div> </div>
+1 -1
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Настройка — LPRA</title> <title>Настройка - LPRA</title>
<link rel="stylesheet" href="/static/app.css"> <link rel="stylesheet" href="/static/app.css">
</head> </head>
<body> <body>
+346
View File
@@ -0,0 +1,346 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Статус · {{.Agent.Name}} - LPRA</title>
<link rel="stylesheet" href="/static/app.css">
<style>
.meters { display:grid; grid-template-columns:repeat(auto-fit, minmax(240px, 1fr)); gap:20px; margin-bottom:20px; }
.meters .card { margin:0; }
.meter .head { display:flex; align-items:baseline; justify-content:space-between; }
.meter .name { color:var(--muted); font-size:13px; }
.meter .val { font-size:26px; font-weight:600; font-variant-numeric:tabular-nums; }
.meter .sub { color:var(--muted); font-size:13px; margin-top:8px; min-height:20px; font-variant-numeric:tabular-nums; }
.bar { height:8px; border-radius:4px; background:#0c0f13; border:1px solid var(--border); overflow:hidden; margin-top:10px; }
.bar i { display:block; height:100%; width:100%; background:var(--accent); transform-origin:left; transform:scaleX(0); transition:transform .4s; }
.bar i.warn { background:#e6a23c; }
.bar i.crit { background:var(--danger); }
.speed { display:flex; gap:28px; flex-wrap:wrap; margin-top:16px; }
.speed b { display:block; font-size:20px; font-variant-numeric:tabular-nums; }
.speed span { color:var(--muted); font-size:13px; }
.live { display:flex; align-items:center; gap:28px; flex-wrap:wrap; margin-top:20px; }
.gauge { position:relative; width:240px; flex:none; }
.gauge svg { display:block; width:100%; }
.gauge path { fill:none; stroke-width:14; stroke-linecap:round; }
.gauge .track { stroke:#0c0f13; }
.gauge .fill { stroke:var(--accent); stroke-dasharray:251.4; stroke-dashoffset:251.4; transition:stroke-dashoffset .25s linear, stroke .3s; }
.gauge .num { position:absolute; left:0; right:0; bottom:26px; text-align:center; font-size:38px; font-weight:600; line-height:1; font-variant-numeric:tabular-nums; }
.gauge .unit { position:absolute; left:0; right:0; bottom:4px; text-align:center; color:var(--muted); font-size:13px; }
.live .side { flex:1; min-width:220px; }
.live .phase { font-size:15px; margin-bottom:8px; }
.live .phase i { font-style:normal; color:var(--accent); margin-right:6px; }
.live.up .phase i { color:var(--ok); }
.live.up .gauge .fill { stroke:var(--ok); }
.spark { display:block; width:100%; height:64px; }
.spark polyline { fill:none; stroke:var(--accent); stroke-width:2; vector-effect:non-scaling-stroke; stroke-linejoin:round; }
.spark polygon { fill:var(--accent); opacity:.15; }
.live.up .spark polyline { stroke:var(--ok); }
.live.up .spark polygon { fill:var(--ok); }
label.check { display:inline-flex; align-items:center; gap:8px; margin:0; color:var(--text); font-size:14px; cursor:pointer; }
</style>
</head>
<body>
<header class="topbar">
<div class="brand">LPRA · Статус - {{.Agent.Name}} <span class="muted">({{.Agent.Hostname}})</span></div>
<nav>
<a href="/">← Машины</a>
<a href="/agents/{{.Agent.ID}}/terminal">Терминал</a>
<a href="/agents/{{.Agent.ID}}/files">Файлы</a>
<a href="/agents/{{.Agent.ID}}/forwards">Порты</a>
<a href="/agents/{{.Agent.ID}}/edit">Изменить</a>
</nav>
</header>
<div class="wrap">
<h1>Статус</h1>
<div class="err" id="err" hidden></div>
<div class="meters">
<div class="card meter">
<div class="head"><span class="name">Процессор</span><span class="val" id="cpu-val">-</span></div>
<div class="bar"><i id="cpu-bar"></i></div>
<div class="sub" id="cpu-sub"></div>
</div>
<div class="card meter">
<div class="head"><span class="name">Оперативная память</span><span class="val" id="mem-val">-</span></div>
<div class="bar"><i id="mem-bar"></i></div>
<div class="sub" id="mem-sub"></div>
</div>
<div class="card meter">
<div class="head"><span class="name">Диск (/)</span><span class="val" id="disk-val">-</span></div>
<div class="bar"><i id="disk-bar"></i></div>
<div class="sub" id="disk-sub"></div>
</div>
</div>
<div class="card">
<h2>Скорость соединения</h2>
<p class="muted">Клиент замеряет свой канал до хоста панели - тот же, по которому идут терминал, файлы и http-проброс.
С галочкой «До браузера» данные идут дальше, через панель до этого браузера и обратно: так виден весь путь клиент - панель - браузер.
Замер занимает около 15 секунд, не дольше 40, и на это время нагружает канал.</p>
<div class="row">
<button id="speed-btn" type="button">Замерить скорость</button>
<label class="check" title="Данные пойдут не только между клиентом и хостом панели, а через панель до этого браузера и обратно">
<input type="checkbox" id="speed-relay"> До браузера
</label>
<span class="muted" id="speed-note"></span>
</div>
<div class="live" id="live" hidden>
<div class="gauge">
<svg viewBox="0 0 200 110">
<path class="track" d="M20 100 A80 80 0 0 1 180 100"/>
<path class="fill" id="lv-arc" d="M20 100 A80 80 0 0 1 180 100"/>
</svg>
<div class="num" id="lv-num">-</div>
<div class="unit" id="lv-unit"></div>
</div>
<div class="side">
<div class="phase"><i id="lv-ico"></i><span id="lv-phase"></span></div>
<svg class="spark" viewBox="0 0 100 100" preserveAspectRatio="none">
<polygon id="lv-area" points=""/>
<polyline id="lv-line" points=""/>
</svg>
</div>
</div>
<div class="speed" id="speed" hidden>
<div><b id="sp-ping"></b><span>задержка</span></div>
<div><b id="sp-down"></b><span id="sp-down-lbl"></span></div>
<div><b id="sp-up"></b><span id="sp-up-lbl"></span></div>
</div>
</div>
</div>
<script>
(function () {
var base = '/api/panel/agents/' + encodeURIComponent({{.Agent.ID}});
var $ = function (id) { return document.getElementById(id); };
var errBox = $('err');
function size(n) {
var u = ['Б', 'КБ', 'МБ', 'ГБ', 'ТБ'], i = 0;
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
return n.toFixed(n < 10 && i > 0 ? 1 : 0) + ' ' + u[i];
}
function rateParts(bps) {
var u = ['бит/с', 'Кбит/с', 'Мбит/с', 'Гбит/с'], i = 0;
while (bps >= 1000 && i < u.length - 1) { bps /= 1000; i++; }
return [bps.toFixed(bps < 100 && i > 0 ? 1 : 0), u[i]];
}
function rate(bps) { return rateParts(bps).join(' '); }
function meter(name, pct, sub) {
pct = Math.max(0, Math.min(100, pct));
$(name + '-val').textContent = Math.round(pct) + '%';
var bar = $(name + '-bar');
bar.style.transform = 'scaleX(' + pct / 100 + ')';
bar.className = pct >= 90 ? 'crit' : pct >= 75 ? 'warn' : '';
$(name + '-sub').textContent = sub;
}
function showErr(msg) {
errBox.textContent = msg || '';
errBox.hidden = !msg;
}
function cores(n) {
var d = n % 10, h = n % 100;
return n + (d === 1 && h !== 11 ? ' ядро' : d >= 2 && d <= 4 && (h < 12 || h > 14) ? ' ядра' : ' ядер');
}
// One request at a time, and none while the tab is hidden: the client is
// sampled only for a page somebody is looking at.
var timer = null, busy = false;
function schedule(ms) {
clearTimeout(timer);
if (!document.hidden) timer = setTimeout(load, ms);
}
function load() {
if (busy || document.hidden) return;
busy = true;
var next = 3000;
fetch(base + '/stat', {credentials: 'same-origin'})
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
.then(function (res) {
if (!res.ok) { showErr(res.error || 'ошибка'); next = 10000; return; }
showErr('');
var d = res.data;
meter('cpu', d.cpu, cores(d.ncpu));
meter('mem', d.mem_total ? 100 * d.mem_used / d.mem_total : 0,
'занято ' + size(d.mem_used) + ' из ' + size(d.mem_total) + ' · свободно ' + size(d.mem_total - d.mem_used));
meter('disk', d.disk_total ? 100 * (d.disk_total - d.disk_free) / d.disk_total : 0,
'свободно ' + size(d.disk_free) + ' из ' + size(d.disk_total));
})
.catch(function (e) { showErr('Нет связи с панелью: ' + e.message); next = 10000; })
.then(function () { busy = false; schedule(next); });
}
document.addEventListener('visibilitychange', function () {
if (document.hidden) clearTimeout(timer); else load();
});
load();
var btn = $('speed-btn'), note = $('speed-note');
function json(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }
function post(path) { return fetch(base + path, {method: 'POST', credentials: 'same-origin'}).then(json); }
// The browser's round trip to the panel: the first request may pay for a
// new connection, so keep the best.
function panelPing() {
var best = Infinity, left = 4;
function one() {
var t = performance.now();
return fetch('/api/panel/ping', {credentials: 'same-origin', cache: 'no-store'})
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.arrayBuffer(); })
.then(function () {
best = Math.min(best, performance.now() - t);
return --left > 0 ? one() : best;
});
}
return one();
}
// Keeps the client's download fed until stopped or the panel says the
// client has read enough; the pieces grow while they go through quickly.
function feed(stop) {
var block = new Uint8Array(65536), n = 4;
crypto.getRandomValues(block);
function one() {
var parts = [], t = performance.now();
for (var i = 0; i < n; i++) parts.push(block);
return fetch(base + '/speed/down', {method: 'POST', body: new Blob(parts), credentials: 'same-origin', signal: stop.signal})
.then(function (r) {
if (r.status !== 204) return;
if (performance.now() - t < 500 && n < 256) n *= 2;
return one();
});
}
one().catch(function () {});
}
// Reads what the client sends and times it from the first byte. It stops by
// itself a little after the client's window, so bytes still queued on a slow
// link don't hold the result back. st.n is the count so far, for the live
// reading.
function drain(st, onFirst) {
var ac = new AbortController(), last = 0;
st.stop = function () { ac.abort(); };
function done() { return st.start && last > st.start ? st.n * 8000 / (last - st.start) : 0; }
return fetch(base + '/speed/up', {credentials: 'same-origin', cache: 'no-store', signal: ac.signal}).then(function (r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
var rd = r.body.getReader();
function pump() {
return rd.read().then(function (c) {
if (c.done) return done();
last = performance.now();
if (!st.start) { st.start = last; setTimeout(st.stop, {{.RelayUpMs}}); onFirst(); }
else st.n += c.value.length;
return pump();
});
}
return pump();
}).catch(function (e) { if (st.start) return done(); throw e; });
}
// The test through the browser: the client's download is what this page
// uploads, and its upload is timed here, where it arrives.
function relayTest(st) {
return panelPing().then(function (ping) {
return post('/speed/relay').then(function (res) {
if (!res.ok) return res;
var stop = new AbortController();
var up = drain(st, function () { stop.abort(); });
up.catch(function () {});
feed(stop);
return post('/speed?relay=1').then(function (res) {
stop.abort();
// No upload under way by now: none is coming.
if (!res.ok || !st.start) { st.stop(); }
if (!res.ok) return res;
return up.catch(function () { return 0; }).then(function (bps) {
res.data.ping_ms += ping;
if (bps) res.data.up_bps = bps;
return res;
});
}, function (e) { stop.abort(); st.stop(); throw e; });
});
});
}
// The live reading: the rate over the last second, from the byte counts the
// panel keeps for the running test - or, for the client's upload in a test
// through the browser, from what has arrived here.
var ARC = 251.4, lv = null;
function liveStart(relay, st) {
lv = {relay: relay, st: st, phase: '', pts: [], hist: [], busy: false};
$('live').hidden = false;
liveShow('ping', 0);
lv.timer = setInterval(liveTick, 200);
}
function liveStop() {
if (!lv) return;
clearInterval(lv.timer);
lv = null;
$('live').hidden = true;
}
function liveTick() {
var cur = lv;
if (cur.relay && cur.st.start) { livePoint(cur, 'up', cur.st.n, performance.now()); return; }
if (cur.busy) return;
cur.busy = true;
fetch(base + '/speed/progress', {credentials: 'same-origin', cache: 'no-store'})
.then(json)
.then(function (d) {
if (cur !== lv || !d.phase || (cur.relay && cur.st.start)) return;
livePoint(cur, d.phase, d.phase === 'up' ? d.up : d.down, d.ms);
})
.catch(function () {})
.then(function () { cur.busy = false; });
}
function livePoint(cur, phase, bytes, ms) {
if (phase !== cur.phase) { cur.phase = phase; cur.pts = []; cur.hist = []; }
if (phase === 'ping') { liveShow(phase, 0); return; }
var pts = cur.pts;
pts.push([ms, bytes]);
while (pts.length > 2 && ms - pts[0][0] > 1000) pts.shift();
if (pts.length < 2 || ms <= pts[0][0]) return;
var bps = (bytes - pts[0][1]) * 8000 / (ms - pts[0][0]);
cur.hist.push(bps);
liveShow(phase, bps, cur);
}
function liveShow(phase, bps, cur) {
var relay = lv && lv.relay, p = rateParts(bps);
$('live').className = 'live' + (phase === 'up' ? ' up' : '');
$('lv-ico').textContent = phase === 'up' ? '↑' : phase === 'down' ? '↓' : '';
$('lv-phase').textContent = phase === 'ping' ? 'Задержка…'
: phase === 'up' ? (relay ? 'клиент → браузер' : 'клиент → хост')
: (relay ? 'браузер → клиент' : 'хост → клиент');
$('lv-num').textContent = phase === 'ping' ? '…' : p[0];
$('lv-unit').textContent = phase === 'ping' ? '' : p[1];
// A log scale up to 1 Гбит/с, so a slow link moves the needle too.
var f = Math.min(1, Math.log(1 + bps / 1e6) / Math.log(1001));
$('lv-arc').style.strokeDashoffset = ARC * (1 - f);
var h = cur ? cur.hist : [], max = 1.25 * Math.max.apply(null, h.concat(1)), line = '';
for (var i = 0; i < h.length; i++) {
line += (h.length > 1 ? 100 * i / (h.length - 1) : 0).toFixed(1) + ',' + (98 - 94 * h[i] / max).toFixed(1) + ' ';
}
$('lv-line').setAttribute('points', line);
$('lv-area').setAttribute('points', h.length > 1 ? '0,100 ' + line + '100,100' : '');
}
btn.onclick = function () {
var relay = $('speed-relay').checked, st = {n: 0, start: 0, stop: function () {}};
btn.disabled = true;
note.textContent = 'Идёт замер…';
$('speed').hidden = true;
liveStart(relay, st);
(relay ? relayTest(st) : post('/speed'))
.then(function (res) {
if (!res.ok) { note.textContent = res.error || 'ошибка'; return; }
var d = res.data;
$('sp-ping').textContent = d.ping_ms ? d.ping_ms.toFixed(d.ping_ms < 10 ? 1 : 0) + ' мс' : '-';
$('sp-down').textContent = rate(d.down_bps);
$('sp-up').textContent = d.up_bps ? rate(d.up_bps) : '-';
$('sp-down-lbl').textContent = relay ? 'браузер → клиент' : 'хост → клиент';
$('sp-up-lbl').textContent = relay ? 'клиент → браузер' : 'клиент → хост';
$('speed').hidden = false;
note.textContent = d.partial ? 'Замер остановлен по времени - показано то, что успело набраться' : '';
})
.catch(function (e) { note.textContent = 'Не удалось: ' + e.message; })
.then(function () { liveStop(); btn.disabled = false; });
};
})();
</script>
</body>
</html>
+131 -6
View File
@@ -3,27 +3,63 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Терминал · {{.Agent.Name}} — LPRA</title> <title>Терминал · {{.Agent.Name}} - LPRA</title>
<link rel="stylesheet" href="/static/app.css"> <link rel="stylesheet" href="/static/app.css">
<link rel="stylesheet" href="/static/xterm.css"> <link rel="stylesheet" href="/static/xterm.css">
<style> <style>
#status.st-ok { color:#37c871; } #status.st-ok { color:#37c871; }
#status.st-err { color:#ff5c5c; } #status.st-err { color:#ff5c5c; }
/* Touch screens: the page is as tall as what the on-screen keyboard leaves,
with a row of extra keys at the bottom, the way Termux does it. */
.keys { display:none; }
body.touch { position:fixed; left:0; right:0; top:0; height:100%; display:flex; flex-direction:column; overflow:hidden; }
body.touch .wrap { flex:1; min-height:0; display:flex; width:100%; margin:0; padding:6px; }
body.touch #terminal { flex:1; min-width:0; height:auto; }
body.touch .keys {
display:grid; grid-template-columns:repeat(7, 1fr); gap:4px; flex:none;
padding:4px 4px calc(4px + env(safe-area-inset-bottom)); background:var(--panel); border-top:1px solid var(--border);
}
.keys button {
height:38px; padding:0; border:1px solid var(--border); border-radius:6px;
background:#0c0f13; color:var(--text); font:500 13px ui-monospace, SFMono-Regular, Menlo, monospace;
touch-action:manipulation; user-select:none; -webkit-user-select:none; -webkit-tap-highlight-color:transparent;
}
.keys button:active { background:var(--border); }
.keys button.on { background:var(--accent); border-color:var(--accent); color:#fff; }
.keys button.lock { background:var(--accent); border-color:#fff; color:#fff; }
</style> </style>
</head> </head>
<body> <body>
<header class="topbar"> <header class="topbar">
<div class="brand">LPRA · Терминал — {{.Agent.Name}} <span class="muted">({{.Agent.TerminalUser}}@{{.Agent.Hostname}})</span></div> <div class="brand">LPRA · Терминал - {{.Agent.Name}} <span class="muted">({{.Agent.TerminalUser}}@{{.Agent.Hostname}})</span></div>
<nav> <nav>
<a href="/">← Машины</a> <a href="/">← Машины</a>
<a href="/agents/{{.Agent.ID}}/files">Файлы</a> <a href="/agents/{{.Agent.ID}}/files">Файлы</a>
<a href="/agents/{{.Agent.ID}}/forwards">Порты</a> <a href="/agents/{{.Agent.ID}}/forwards">Порты</a>
<a href="/agents/{{.Agent.ID}}/status">Статус</a>
<a href="/agents/{{.Agent.ID}}/edit">Изменить</a>
<span id="status" class="muted" style="margin-left:28px">подключение…</span> <span id="status" class="muted" style="margin-left:28px">подключение…</span>
</nav> </nav>
</header> </header>
<div class="wrap" style="max-width:none"> <div class="wrap" style="max-width:none">
<div id="terminal"></div> <div id="terminal"></div>
</div> </div>
<div class="keys" id="keys">
<button type="button" data-k="esc">ESC</button>
<button type="button" data-k="/">/</button>
<button type="button" data-k="-">-</button>
<button type="button" data-k="home">HOME</button>
<button type="button" data-k="up">↑</button>
<button type="button" data-k="end">END</button>
<button type="button" data-k="pgup">PGUP</button>
<button type="button" data-k="tab">TAB</button>
<button type="button" data-k="ctrl" data-mod="ctrl">CTRL</button>
<button type="button" data-k="alt" data-mod="alt">ALT</button>
<button type="button" data-k="left">←</button>
<button type="button" data-k="down">↓</button>
<button type="button" data-k="right">→</button>
<button type="button" data-k="pgdn">PGDN</button>
</div>
<script src="/static/xterm.js"></script> <script src="/static/xterm.js"></script>
<script src="/static/addon-fit.js"></script> <script src="/static/addon-fit.js"></script>
@@ -31,11 +67,13 @@
(function () { (function () {
const agentID = {{.Agent.ID}}; const agentID = {{.Agent.ID}};
const statusEl = document.getElementById('status'); const statusEl = document.getElementById('status');
const touch = window.matchMedia('(pointer: coarse)').matches;
if (touch) document.body.classList.add('touch');
function setStatus(t, cls) { statusEl.textContent = t; statusEl.className = cls || ''; } function setStatus(t, cls) { statusEl.textContent = t; statusEl.className = cls || ''; }
const term = new Terminal({ const term = new Terminal({
cursorBlink: true, cursorBlink: true,
fontSize: 14, fontSize: touch ? 13 : 14,
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
theme: { background: '#0c0f13' } theme: { background: '#0c0f13' }
}); });
@@ -78,7 +116,7 @@
setStatus('ошибка открытия (' + r.status + ')', 'st-err'); setStatus('ошибка открытия (' + r.status + ')', 'st-err');
term.write('\r\n\x1b[31m[не удалось открыть сессию: HTTP ' + r.status + ']\x1b[0m\r\n'); term.write('\r\n\x1b[31m[не удалось открыть сессию: HTTP ' + r.status + ']\x1b[0m\r\n');
if (body) term.write(body.slice(0, 300).replace(/\n/g, '\r\n') + '\r\n'); if (body) term.write(body.slice(0, 300).replace(/\n/g, '\r\n') + '\r\n');
if (r.status === 404) term.write('\x1b[33mМашина не найдена на сервере — возможно, устаревший бинарник сервера без маршрута /open.\x1b[0m\r\n'); if (r.status === 404) term.write('\x1b[33mМашина не найдена на сервере - возможно, устаревший бинарник сервера без маршрута /open.\x1b[0m\r\n');
return; return;
} }
const j = await r.json(); const j = await r.json();
@@ -124,17 +162,104 @@
term.write('\r\n\x1b[33m' + msg + '\x1b[0m\r\n'); term.write('\r\n\x1b[33m' + msg + '\x1b[0m\r\n');
} }
term.onData(function (data) { function send(data) {
if (closed || !sid) return; if (closed || !sid) return;
const bytes = new TextEncoder().encode(data); const bytes = new TextEncoder().encode(data);
post(base + sid + '/in', { t: 'i', d: bytesToB64(bytes) }); post(base + sid + '/in', { t: 'i', d: bytesToB64(bytes) });
}
// What the keyboard types gets the CTRL / ALT of the extra keys, if armed.
term.onData(function (data) { send(withMods(data)); });
// --- Extra keys (touch screens) ---
// CTRL and ALT: a tap arms the key for the next key press, a long press
// keeps it on until tapped again.
const mods = { ctrl: 0, alt: 0 }; // 0 off, 1 next key, 2 locked
const modBtn = {};
document.querySelectorAll('#keys [data-mod]').forEach(function (b) { modBtn[b.dataset.mod] = b; });
function showMods() {
for (const m in mods) {
modBtn[m].classList.toggle('on', mods[m] === 1);
modBtn[m].classList.toggle('lock', mods[m] === 2);
}
}
function useMods() {
const used = { ctrl: !!mods.ctrl, alt: !!mods.alt };
for (const m in mods) if (mods[m] === 1) mods[m] = 0;
showMods();
return used;
}
function ctrlChar(c) {
if (c === ' ' || c === '@') return '\x00';
if (c === '?') return '\x7f';
const code = c.toUpperCase().charCodeAt(0);
return code >= 64 && code <= 95 ? String.fromCharCode(code & 31) : c;
}
function withMods(data) {
if (!mods.ctrl && !mods.alt) return data;
const m = useMods();
if (m.ctrl && data.length === 1) data = ctrlChar(data);
return m.alt ? '\x1b' + data : data;
}
// xterm's modifier parameter: 1 + shift(1) + alt(2) + ctrl(4).
const CSI = { up: 'A', down: 'B', right: 'C', left: 'D', home: 'H', end: 'F' };
const TILDE = { pgup: 5, pgdn: 6 };
function keySeq(k) {
const m = useMods(), p = 1 + (m.alt ? 2 : 0) + (m.ctrl ? 4 : 0);
if (CSI[k]) {
if (p > 1) return '\x1b[1;' + p + CSI[k];
return (term.modes.applicationCursorKeysMode ? '\x1bO' : '\x1b[') + CSI[k];
}
if (TILDE[k]) return '\x1b[' + TILDE[k] + (p > 1 ? ';' + p : '') + '~';
let s = k === 'esc' ? '\x1b' : k === 'tab' ? '\t' : k;
if (m.ctrl && s.length === 1) s = ctrlChar(s);
return m.alt ? '\x1b' + s : s;
}
let press = null;
document.querySelectorAll('#keys button').forEach(function (b) {
// Keep the focus (and the on-screen keyboard) on the terminal.
b.addEventListener('pointerdown', function (e) {
e.preventDefault();
const mod = b.dataset.mod;
if (mod) press = { mod: mod, timer: setTimeout(function () { mods[mod] = 2; press = null; showMods(); }, 450) };
});
b.addEventListener('pointerup', function (e) {
e.preventDefault();
const mod = b.dataset.mod;
if (mod) {
if (press && press.mod === mod) {
clearTimeout(press.timer);
press = null;
mods[mod] = mods[mod] ? 0 : 1;
showMods();
}
return;
}
send(keySeq(b.dataset.k));
});
b.addEventListener('pointercancel', function () { if (press) { clearTimeout(press.timer); press = null; } });
b.addEventListener('contextmenu', function (e) { e.preventDefault(); });
}); });
function sendResize() { function sendResize() {
if (closed || !sid) return; if (closed || !sid) return;
post(base + sid + '/in', { t: 'r', cols: term.cols, rows: term.rows }); post(base + sid + '/in', { t: 'r', cols: term.cols, rows: term.rows });
} }
window.addEventListener('resize', function () { fit.fit(); sendResize(); }); function refit() {
if (touch && window.visualViewport) {
// Follow the visible area, so the extra keys sit right above the
// on-screen keyboard.
const vv = window.visualViewport;
document.body.style.top = vv.offsetTop + 'px';
document.body.style.height = vv.height + 'px';
}
fit.fit();
}
window.addEventListener('resize', refit);
if (touch && window.visualViewport) {
window.visualViewport.addEventListener('resize', refit);
window.visualViewport.addEventListener('scroll', refit);
}
refit();
term.onResize(sendResize); term.onResize(sendResize);
window.addEventListener('pagehide', function () { window.addEventListener('pagehide', function () {
+1 -1
View File
@@ -1,5 +1,5 @@
// Package tunnel defines the framed multiplexing protocol used for DIRECT port // Package tunnel defines the framed multiplexing protocol used for DIRECT port
// forwarding over the dedicated host port (default 8446). Unlike the HTTP // forwarding over the dedicated host port (DefaultPort). Unlike the HTTP
// long-poll forward, this is a raw TCP stream the agent dials outbound; it // long-poll forward, this is a raw TCP stream the agent dials outbound; it
// carries many concurrent forwarded connections (streams) for one agent and // carries many concurrent forwarded connections (streams) for one agent and
// has far lower latency than long-polling. // has far lower latency than long-polling.