15 Commits
Author SHA1 Message Date
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
redtea 328ade651c fix: fix ufw 2026-10-13 16:16:31 +03:00
redtea 5e6e0b8a3a chore: run the server unit as root from /opt/lpra
The panel's ufw integration needs root; the database now lives next to
the binary in /opt/lpra.
2026-10-13 14:55:37 +03:00
redtea bcfe909aaa feat: tunnel host and port in the panel settings, ufw kept in sync
The direct tunnel port (8446 by default) and the host agents dial for
it can be set in Settings. The server moves its listeners at once and
agents pick the address up from the panel: on connect and with every
poll, unless TUNNEL_ADDR / TUNNEL_PORT pin it locally.

ufw: run with a C locale (the output is translated), do not adopt rules
that existed before the panel, skip loopback-only listeners, re-open
what saved forwards need on start and when the integration is turned
on, move the shared tunnel rule with the port and drop it when no
direct forward is left. The last ufw error is shown in Settings.

The forwards page shows the real tunnel and panel ports.
2026-10-13 14:50:50 +03:00
redtea 08fc2d79a8 fix: take the file edit limit in the panel from the agent's read cap
The limit was repeated in the page; a file of exactly the cap is read
in full, so it is editable too.
2026-10-13 14:50:49 +03:00
redtea 7cb9da8569 chore: take the version from VERSION.txt only (0.0.3)
VERSION.txt is embedded into the server and the agent, so a plain
go build reports the real version and build.sh no longer injects it.
2026-10-13 14:50:49 +03:00
redtea 93673d3570 style: match the user select to the panel buttons 2026-10-13 01:43:24 +03:00
redtea 3dde07f403 feat: terminal as a chosen OS user, create users from the panel (0.0.2)
- agent reports the OS user it runs as (enroll + poll header)
- per-machine terminal user on the edit page; defaults to the agent's own user (root on Linux)
- create a new user from the panel: name, password, optional sudo group
- show the reason in the terminal when a shell fails to start
- bump version to 0.0.2
2026-10-13 01:28:57 +03:00
47 changed files with 2139 additions and 251 deletions

No files matched your search

+32 -9
View File
@@ -1,8 +1,8 @@
# LPRA — LongPolling Remote Access
# LPRA - LongPolling Remote Access
Лёгкий self-hosted сервис для доступа к Linux-машинам **без белого IP** (за NAT).
Агент на клиенте сам исходяще подключается к серверу по HTTP long-polling; админ
в веб-панели видит список машин и открывает по каждой **root-терминал в браузере**.
в веб-панели видит список машин и открывает по каждой **терминал в браузере** (по умолчанию от root, пользователя можно сменить).
```
браузер ──WS──> [nginx :8445 TLS] ──> [Go server 127.0.0.1:8091] <──long-poll── агент (root, за NAT)
@@ -11,9 +11,9 @@
```
## Компоненты
- **Сервер** (`cmd/server`) — админ-панель + точка встречи для агентов. За nginx (TLS).
- **Агент** (`cmd/agent`) — Linux-бинарь, systemd-сервис от root, PTY по запросу.
- **install.sh** — интерактивный установщик, отдаётся сервером как статика.
- **Сервер** (`cmd/server`) - админ-панель + точка встречи для агентов. За nginx (TLS).
- **Агент** (`cmd/agent`) - Linux-бинарь, systemd-сервис от root, PTY по запросу.
- **install.sh** - интерактивный установщик, отдаётся сервером как статика.
## Сборка
```sh
@@ -28,7 +28,7 @@
Поставьте перед ним nginx по `deploy/nginx.conf.example` (TLS, long-poll/WS).
Локально без TLS можно добавить `-insecure-cookies`.
Первый запуск: откройте `/setup` — мастер создаст администратора. Дальше `/settings`:
Первый запуск: откройте `/setup` - мастер создаст администратора. Дальше `/settings`:
адрес хоста (по умолчанию `https://localhost:8445`), enroll-секрет и готовая
команда установки.
@@ -37,15 +37,38 @@
```sh
curl -fsSL https://localhost:8445/install.sh | sudo HOST=https://localhost:8445 ENROLL_SECRET=<секрет> sh
```
Или интерактивно (спросит секрет, а если агент уже стоит — предложит удалить):
Или интерактивно (спросит секрет, а если агент уже стоит - предложит удалить):
```sh
curl -fsSL https://localhost:8445/install.sh | sudo sh
```
## Проброс портов и прямой туннель
Правила проброса создаются на странице машины. Режим `http` идёт через адрес панели (long-poll),
режимы `tcp`/`udp` - через прямой туннель: агент держит исходящее соединение к порту туннеля хоста
(флаг `-tunnel` / `LPRA_TUNNEL_ADDR`).
Порт туннеля и хост, к которому для него подключаются агенты (например, IP вместо домена панели),
меняются в `/settings` → «Прямой туннель». Агенты забирают адрес с панели сами - при подключении и
при каждом опросе, переустановка не нужна. Локальные `TUNNEL_ADDR` / `TUNNEL_PORT` в окружении агента
имеют приоритет над настройкой панели (`TUNNEL_ADDR=off` отключает туннель).
Если на хосте активен ufw и сервер запущен от root, панель сама открывает порт хоста правила
(tcp для `http`/`tcp`, udp для `udp`) и порт туннеля для прямых режимов, а при удалении правила - закрывает.
При `ProtectSystem=strict` в systemd-юните ufw нужны права на запись: `ReadWritePaths=/etc/ufw /run`.
## Версия
Версия задаётся в одном месте - `VERSION.txt`; файл вшивается в сервер и агент при сборке.
## Пользователь терминала
По умолчанию терминал открывается от пользователя, под которым запущен агент (на Linux - root).
В окне «Изменить машину» можно выбрать другого пользователя машины или создать нового
(имя, пароль, опционально - добавить в sudo). Смена и создание работают, только если агент запущен от root.
Файловый менеджер по-прежнему работает от пользователя агента.
## Безопасность
- Пароли админов — bcrypt; сессии в HttpOnly/Secure/SameSite куках; CSRF на формах; rate-limit логина.
- Пароли админов - bcrypt; сессии в HttpOnly/Secure/SameSite куках; CSRF на формах; rate-limit логина.
- Агенты: общий enroll-секрет → индивидуальный токен (хранится хешем, сверка constant-time).
- Открытие терминала пишется в таблицу `audit` (терминал даёт root-доступ).
- Открытие терминала пишется в таблицу `audit` (терминал даёт root-доступ), как и смена/создание пользователя.
## Скриншоты
<img width="1256" height="709" alt="image" src="https://github.com/user-attachments/assets/5f0b30f1-d5e3-4b62-817e-4041641df6e3" />
+1 -1
View File
@@ -1 +1 @@
0.0.1
0.0.5
+3 -4
View File
@@ -5,10 +5,9 @@ set -eu
cd "$(dirname "$0")"
VERSION="${VERSION:-$(cat VERSION.txt 2>/dev/null || echo 0.0.0)}"
PKG="github.com/x-grk/longpolling-remote-access"
LDFLAGS="-s -w -X $PKG/internal/server.Version=$VERSION -X $PKG/internal/agent.Version=$VERSION"
echo ">> version $VERSION"
# The version comes from VERSION.txt, which is embedded into every binary.
LDFLAGS="-s -w"
echo ">> version $(cat VERSION.txt)"
mkdir -p dist bin
+9 -4
View File
@@ -10,9 +10,11 @@ import (
"net/url"
"os"
"os/signal"
"strconv"
"syscall"
"github.com/x-grk/longpolling-remote-access/internal/agent"
"github.com/x-grk/longpolling-remote-access/internal/tunnel"
)
func main() {
@@ -23,6 +25,8 @@ func main() {
Name: os.Getenv("AGENT_NAME"),
StatePath: envOr("STATE_PATH", "/etc/lpagent/state.json"),
TunnelAddr: tunnelAddr(host),
// A locally set address wins over the one from the panel settings.
TunnelPinned: os.Getenv("TUNNEL_ADDR") != "" || os.Getenv("TUNNEL_PORT") != "",
}
if cfg.EnrollSecret == "" {
// Only required for the very first run (before enrollment).
@@ -46,9 +50,10 @@ func envOr(key, def string) string {
return def
}
// tunnelAddr derives the direct-tunnel address (host:8446) from the server URL.
// TUNNEL_ADDR overrides it entirely; TUNNEL_PORT overrides just the port.
// Set TUNNEL_ADDR=off to disable the direct tunnel.
// 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
// 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.
func tunnelAddr(hostURL string) string {
if v := os.Getenv("TUNNEL_ADDR"); v != "" {
if v == "off" {
@@ -60,5 +65,5 @@ func tunnelAddr(hostURL string) string {
if err != nil || u.Hostname() == "" {
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"
"net/http"
"os"
"strconv"
"time"
"github.com/x-grk/longpolling-remote-access/internal/server"
"github.com/x-grk/longpolling-remote-access/internal/tunnel"
)
func main() {
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")
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",
"do not mark cookies Secure (plain-HTTP local testing only)")
flag.Parse()
+17 -20
View File
@@ -1,24 +1,24 @@
# systemd unit for the LPRA host (server).
#
# Unlike the agent, the server does NOT need root: it listens on a high local
# port (127.0.0.1:8091) behind nginx, so run it as an unprivileged user.
# The server runs as root so that the panel can open forwarded ports in ufw;
# it listens on a high local port (127.0.0.1:8091) behind nginx.
#
# Install:
# sudo useradd --system --home /opt/lpra --shell /usr/sbin/nologin lpra
# sudo mkdir -p /opt/lpra /var/lib/lpra
# sudo mkdir -p /opt/lpra
# sudo cp bin/lpra-server /opt/lpra/
# sudo cp -r dist /opt/lpra/dist # agent binaries served at /download/
# sudo chown -R lpra:lpra /opt/lpra /var/lib/lpra
# sudo cp deploy/lpra-server.service /etc/systemd/system/
# sudo systemctl daemon-reload && sudo systemctl enable --now lpra-server
#
# Config overrides can go in /etc/lpra-server.env (see EnvironmentFile below),
# e.g. LPRA_ADDR=127.0.0.1:8091
# Direct port forwarding (tcp/udp modes) uses the tunnel port on the host, which
# has to be open in the firewall for tcp and udp. (The "http" forward mode and
# terminal/files go through nginx :8445 and need nothing extra.) Set the tunnel
# 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
# settings; agents pick the new address up from the panel by themselves.
#
# Direct port forwarding (tcp/udp modes) uses port 8446 on the host — open it in
# the firewall: ufw allow 8446/tcp ; ufw allow 8446/udp
# (The "http" forward mode and terminal/files go through nginx :8445 and need
# nothing extra.) Override with LPRA_TUNNEL_ADDR, or set it empty to disable.
# ufw integration (the panel opening forwarded ports itself) needs root and a
# writable /etc/ufw and /run - see ReadWritePaths below.
[Unit]
Description=LPRA remote access host (admin panel + agent rendezvous)
@@ -26,17 +26,11 @@ After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=lpra
Group=lpra
WorkingDirectory=/opt/lpra
# Optional environment overrides; "-" means the file is not required.
EnvironmentFile=-/etc/lpra-server.env
ExecStart=/opt/lpra/lpra-server \
-addr 127.0.0.1:8091 \
-db /var/lib/lpra/lpra.db \
-db /opt/lpra/lpra.db \
-dist /opt/lpra/dist
Restart=always
@@ -51,8 +45,11 @@ PrivateDevices=true
ProtectKernelTunables=true
ProtectControlGroups=true
RestrictSUIDSGID=true
# The server only needs to write its SQLite database here.
ReadWritePaths=/var/lib/lpra
# The SQLite database, plus what ufw writes: its rules in /etc/ufw and the
# ufw/xtables locks in /run. Without the last two "ufw status" works but
# "ufw allow" fails with "'/etc/ufw/user.rules' is not writable".
ReadWritePaths=/opt/lpra /etc/ufw /run
ReadWritePaths-=/etc/ufw /run
[Install]
WantedBy=multi-user.target
+1 -1
View File
@@ -5,6 +5,7 @@ go 1.26
require (
github.com/creack/pty v1.1.21
golang.org/x/crypto v0.31.0
golang.org/x/sys v0.28.0
modernc.org/sqlite v1.24.0
)
@@ -13,7 +14,6 @@ require (
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.16 // 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/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
+85 -11
View File
@@ -13,17 +13,19 @@ import (
"log"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"runtime"
"sync"
"time"
lpra "github.com/x-grk/longpolling-remote-access"
"github.com/x-grk/longpolling-remote-access/internal/proto"
)
// Version is the agent build version, injected at compile time via -ldflags
// (-X .../internal/agent.Version=...).
var Version = "dev"
// Version is the agent build version (see VERSION.txt).
var Version = lpra.Version
// Config is the agent's runtime configuration (from env / config.env).
type Config struct {
@@ -31,7 +33,10 @@ type Config struct {
EnrollSecret string
Name string // optional friendly name
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 /
// TUNNEL_PORT) instead of following the address set in the panel.
TunnelPinned bool
}
type state struct {
@@ -45,6 +50,11 @@ type Agent struct {
http *http.Client
state state
pollCount int
// Direct tunnel currently running (see setTunnelAddr).
tunMu sync.Mutex
tunAddr string
tunCancel context.CancelFunc
}
// New builds an agent. It does not perform any network I/O.
@@ -87,10 +97,11 @@ func (a *Agent) Run(ctx context.Context) error {
}
log.Printf("agent %s connected to %s", a.state.AgentID, a.cfg.Host)
// Direct forward tunnel (low-latency TCP mux + UDP) on 8446, if configured.
if a.cfg.TunnelAddr != "" {
go a.runTunnel(ctx)
go a.runUDP(ctx)
// Direct forward tunnel (low-latency TCP mux + UDP). Its address comes
// from the panel; fall back to the configured one when the server sends
// none (tunnel port left at the default on an older server).
if a.cfg.TunnelPinned || !a.fetchConfig(ctx) {
a.setTunnelAddr(ctx, a.cfg.TunnelAddr)
}
backoff := time.Second
@@ -120,6 +131,7 @@ func (a *Agent) enroll(ctx context.Context) error {
OS: runtime.GOOS,
Arch: runtime.GOARCH,
Version: Version,
User: currentUser(),
})
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, a.cfg.Host+"/api/agent/enroll", bytes.NewReader(body))
req.Header.Set("X-Enroll-Secret", a.cfg.EnrollSecret)
@@ -145,11 +157,12 @@ func (a *Agent) enroll(ctx context.Context) error {
func (a *Agent) pollOnce(ctx context.Context) error {
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, a.cfg.Host+"/api/agent/poll", nil)
a.authHeaders(req)
// Report the version occasionally (not every poll), so the panel can flag
// outdated agents without extra traffic.
// Report the version and our OS user occasionally (not every poll), so the
// panel can flag outdated agents without extra traffic.
a.pollCount++
if a.pollCount%20 == 1 {
req.Header.Set("X-Agent-Version", Version)
req.Header.Set("X-Agent-User", currentUser())
}
resp, err := a.http.Do(req)
if err != nil {
@@ -166,10 +179,11 @@ func (a *Agent) pollOnce(ctx context.Context) error {
if err := json.NewDecoder(resp.Body).Decode(&msg); err != nil {
return err
}
a.applyTunnelHeader(ctx, resp.Header)
// Dispatch in the background so we keep polling for more work.
switch msg.Type {
case proto.CtrlOpenTerminal:
go a.runTerminal(ctx, msg.SessionID)
go a.runTerminal(ctx, msg.SessionID, msg.User)
case proto.CtrlOpenForward:
go a.runForward(ctx, msg.SessionID, msg.Port)
case proto.CtrlTask:
@@ -178,6 +192,66 @@ func (a *Agent) pollOnce(ctx context.Context) error {
return nil
}
// fetchConfig asks the server for the settings it hands to agents and reports
// whether it named a tunnel address.
func (a *Agent) fetchConfig(ctx context.Context) bool {
ctx2, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx2, http.MethodGet, a.cfg.Host+"/api/agent/config", nil)
a.authHeaders(req)
resp, err := a.http.Do(req)
if err != nil {
return false
}
defer resp.Body.Close()
return a.applyTunnelHeader(ctx, resp.Header)
}
// applyTunnelHeader follows the tunnel address the server advertises as
// "host:port"; an empty host means the host we already reach the server on.
func (a *Agent) applyTunnelHeader(ctx context.Context, h http.Header) bool {
v := h.Get("X-Tunnel-Addr")
if v == "" || a.cfg.TunnelPinned {
return false
}
host, port, err := net.SplitHostPort(v)
if err != nil {
return false
}
if host == "" {
u, err := url.Parse(a.cfg.Host)
if err != nil || u.Hostname() == "" {
return false
}
host = u.Hostname()
}
a.setTunnelAddr(ctx, net.JoinHostPort(host, port))
return true
}
// setTunnelAddr points the direct tunnel at addr, restarting it if it was
// running against another address. An empty addr stops it.
func (a *Agent) setTunnelAddr(ctx context.Context, addr string) {
a.tunMu.Lock()
defer a.tunMu.Unlock()
if addr == a.tunAddr {
return
}
if a.tunCancel != nil {
a.tunCancel()
a.tunCancel = nil
}
a.tunAddr = addr
if addr == "" {
return
}
log.Printf("tunnel: using %s", addr)
tctx, cancel := context.WithCancel(ctx)
a.tunCancel = cancel
go a.runTunnel(tctx, addr)
go a.runUDP(tctx, addr)
}
func (a *Agent) authHeaders(req *http.Request) {
req.Header.Set("X-Agent-Id", a.state.AgentID)
req.Header.Set("Authorization", "Bearer "+a.state.Token)
+2 -2
View File
@@ -17,8 +17,8 @@ import (
"github.com/x-grk/longpolling-remote-access/internal/proto"
)
// fsReadCap bounds how many bytes a view/read returns (5 MiB).
const fsReadCap = 5 * 1024 * 1024
// fsReadCap bounds how many bytes a view/read returns.
const fsReadCap = proto.FSReadCap
// fsDownloadCap bounds a browser download (kept under the 64 MiB result/body
// limit once base64-encoded).
+6 -3
View File
@@ -15,14 +15,17 @@ import (
)
// runTerminal spawns a PTY and bridges it to the session.
func (a *Agent) runTerminal(parent context.Context, sid string) {
term, err := StartPTY()
// user is the OS user to run the shell as ("" = the agent's own user).
func (a *Agent) runTerminal(parent context.Context, sid, user string) {
term, err := StartPTY(user)
if err != nil {
log.Printf("term %s: start pty: %v", sid, err)
// Show the reason in the browser terminal before closing it.
a.postOut(context.Background(), sid, proto.OutMessage{Data: b64.EncodeToString([]byte("lpra: " + err.Error() + "\r\n"))})
a.postOut(context.Background(), sid, proto.OutMessage{Exit: true})
return
}
log.Printf("term %s: shell started", sid)
log.Printf("term %s: shell started (user %q)", sid, user)
a.bridgeStream(parent, sid, term, term.Resize)
log.Printf("term %s: ended", sid)
}
+122
View File
@@ -0,0 +1,122 @@
package agent
import (
"context"
"crypto/rand"
"encoding/json"
"io"
"net/http"
"time"
"github.com/x-grk/longpolling-remote-access/internal/proto"
)
const (
// speedWindow is how long each direction is measured for.
speedWindow = 3 * time.Second
// speedUpCap bounds the upload, whatever the link does in speedWindow.
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 speedWindow.
func (a *Agent) doSpeed(ctx context.Context) proto.TaskResult {
res, err := a.measureSpeed(ctx)
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) (proto.SysSpeedResult, error) {
var res proto.SysSpeedResult
// 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: read what the server streams until the window closes.
dctx, cancel := context.WithTimeout(ctx, speedWindow)
resp, err := a.speedRequest(dctx, http.MethodGet, "/api/agent/speed/down", nil)
if err != nil {
cancel()
return res, err
}
start := time.Now()
n, _ := io.Copy(io.Discard, resp.Body)
elapsed := time.Since(start)
resp.Body.Close()
cancel()
res.DownBps = bps(n, elapsed)
// Up: send until the window closes or the cap is hit.
body := &speedSource{deadline: time.Now().Add(speedWindow), left: speedUpCap}
start = time.Now()
resp, err = a.speedRequest(ctx, http.MethodPost, "/api/agent/speed/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
}
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
}
+8 -4
View File
@@ -17,9 +17,12 @@ func (a *Agent) runTask(ctx context.Context, taskID string) {
return
}
var res proto.TaskResult
if env.Type == proto.TaskSysUpdate {
switch env.Type {
case proto.TaskSysUpdate:
res = a.doUpdate()
} else {
case proto.TaskSysSpeed:
res = a.doSpeed(ctx)
default:
res = a.execTask(env)
}
a.postResult(ctx, taskID, res)
@@ -54,9 +57,10 @@ func (a *Agent) postResult(ctx context.Context, taskID string, res proto.TaskRes
resp.Body.Close()
}
// execTask runs a single file-manager operation natively and returns a result.
// execTask runs a single file-manager or user-management operation natively
// and returns a result.
func (a *Agent) execTask(env proto.TaskEnvelope) proto.TaskResult {
data, err := dispatchFS(env)
data, err := dispatchTask(env)
if err != nil {
return proto.TaskResult{Error: err.Error()}
}
+24 -7
View File
@@ -1,6 +1,7 @@
package agent
import (
"fmt"
"os"
"os/exec"
@@ -13,14 +14,30 @@ type PTY struct {
f *os.File
}
// StartPTY launches the shell attached to a new PTY. The agent runs as root
// (systemd User=root), so this is a root shell by design.
func StartPTY() (*PTY, error) {
shell := "/bin/bash"
if _, err := os.Stat(shell); err != nil {
shell = "/bin/sh"
// 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,
// 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.
func StartPTY(user string) (*PTY, error) {
var cmd *exec.Cmd
if user == "" || user == currentUser() {
cmd = exec.Command(defaultShell(), "-l")
} else {
if !validUserName(user) {
return nil, fmt.Errorf("недопустимое имя пользователя %q", user)
}
if os.Geteuid() != 0 {
return nil, fmt.Errorf("агент запущен от %s и не может открыть сессию от %s", currentUser(), user)
}
if !userExists(user) {
return nil, fmt.Errorf("пользователь %s не найден", user)
}
su, err := exec.LookPath("su")
if err != nil {
return nil, fmt.Errorf("su не найден: %w", err)
}
cmd = exec.Command(su, "-l", user)
}
cmd := exec.Command(shell, "-l")
cmd.Env = append(os.Environ(), "TERM=xterm-256color")
f, err := pty.Start(cmd)
if err != nil {
+17 -13
View File
@@ -15,12 +15,12 @@ import (
// --- direct TCP mux tunnel ---
// runTunnel keeps a persistent connection to the host's 8446 tunnel, dialing
// client-side ports on demand for "tcp" forward rules.
func (a *Agent) runTunnel(ctx context.Context) {
// runTunnel keeps a persistent connection to the host's tunnel at addr, dialing
// client-side ports on demand for "tcp" forward rules, until ctx is cancelled.
func (a *Agent) runTunnel(ctx context.Context, addr string) {
backoff := time.Second
for ctx.Err() == nil {
if err := a.tunnelOnce(ctx); err != nil {
if err := a.tunnelOnce(ctx, addr); err != nil {
select {
case <-time.After(backoff):
case <-ctx.Done():
@@ -35,13 +35,15 @@ func (a *Agent) runTunnel(ctx context.Context) {
}
}
func (a *Agent) tunnelOnce(ctx context.Context) error {
func (a *Agent) tunnelOnce(ctx context.Context, addr string) error {
d := net.Dialer{Timeout: 10 * time.Second}
conn, err := d.DialContext(ctx, "tcp", a.cfg.TunnelAddr)
conn, err := d.DialContext(ctx, "tcp", addr)
if err != nil {
return err
}
defer conn.Close()
// Unblock the read loop when the tunnel is stopped or moved.
defer context.AfterFunc(ctx, func() { conn.Close() })()
if tc, ok := conn.(*net.TCPConn); ok {
_ = tc.SetNoDelay(true)
}
@@ -65,7 +67,7 @@ func (a *Agent) tunnelOnce(ctx context.Context) error {
return err
}
_ = conn.SetDeadline(time.Time{})
log.Printf("tunnel: connected to %s", a.cfg.TunnelAddr)
log.Printf("tunnel: connected to %s", addr)
tc := &tunClient{conn: conn, streams: map[uint32]*agentStream{}}
return tc.readLoop()
@@ -194,12 +196,12 @@ func (t *tunClient) shutdown() {
// --- direct UDP forwarding ---
// runUDP maintains the agent's UDP endpoint on the host's 8446 channel and
// runUDP maintains the agent's UDP endpoint on the host's tunnel at addr and
// relays datagrams to/from client-side UDP ports for "udp" forward rules.
func (a *Agent) runUDP(ctx context.Context) {
func (a *Agent) runUDP(ctx context.Context, addr string) {
backoff := time.Second
for ctx.Err() == nil {
if err := a.udpOnce(ctx); err != nil {
if err := a.udpOnce(ctx, addr); err != nil {
select {
case <-time.After(backoff):
case <-ctx.Done():
@@ -214,8 +216,8 @@ func (a *Agent) runUDP(ctx context.Context) {
}
}
func (a *Agent) udpOnce(ctx context.Context) error {
raddr, err := net.ResolveUDPAddr("udp", a.cfg.TunnelAddr)
func (a *Agent) udpOnce(ctx context.Context, addr string) error {
raddr, err := net.ResolveUDPAddr("udp", addr)
if err != nil {
return err
}
@@ -224,6 +226,8 @@ func (a *Agent) udpOnce(ctx context.Context) error {
return err
}
defer conn.Close()
// Unblock the read loop when the tunnel is stopped or moved.
defer context.AfterFunc(ctx, func() { conn.Close() })()
key := tunnel.AuthKey(a.state.Token)
register := func() {
@@ -268,7 +272,7 @@ func (a *Agent) udpOnce(ctx context.Context) error {
}
type udpClient struct {
server *net.UDPConn // connection back to host:8446
server *net.UDPConn // connection back to the host tunnel
mu sync.Mutex
dials map[uint32]*net.UDPConn // sid -> client-side socket
}
+1 -1
View File
@@ -17,7 +17,7 @@ import (
// doUpdate downloads the latest agent binary for this platform, atomically
// replaces the running executable, and schedules a service restart. It never
// uninstalls — only updates.
// uninstalls - only updates.
func (a *Agent) doUpdate() proto.TaskResult {
exe, err := os.Executable()
if err != nil {
+237
View File
@@ -0,0 +1,237 @@
package agent
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"os/user"
"regexp"
"runtime"
"sort"
"strconv"
"strings"
"github.com/x-grk/longpolling-remote-access/internal/proto"
)
// userNameRe is the login name shape accepted for existing users; it keeps a
// name from ever being parsed as an option by su. newUserNameRe is the stricter
// shape useradd accepts everywhere.
var (
userNameRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_.-]{0,31}$`)
newUserNameRe = regexp.MustCompile(`^[a-z_][a-z0-9_-]{0,31}$`)
)
func validUserName(name string) bool { return userNameRe.MatchString(name) }
// dispatchTask runs one task: user management here, everything else is a
// file-manager operation.
func dispatchTask(env proto.TaskEnvelope) (any, error) {
switch env.Type {
case proto.TaskSysUsers:
return sysUsers(), nil
case proto.TaskSysStat:
return sysStat()
case proto.TaskSysUserAdd:
var a proto.SysUserAddArgs
_ = json.Unmarshal(env.Args, &a)
return nil, addUser(a)
}
return dispatchFS(env)
}
// currentUser is the OS user the agent process runs as.
func currentUser() string {
if u, err := user.Current(); err == nil && u.Username != "" {
return u.Username
}
if os.Geteuid() == 0 {
return "root"
}
if n := os.Getenv("USER"); n != "" {
return n
}
return strconv.Itoa(os.Geteuid())
}
func userExists(name string) bool {
if _, err := user.Lookup(name); err == nil {
return true
}
// Without cgo, os/user only reads /etc/passwd, which macOS doesn't use.
if runtime.GOOS == "darwin" {
for _, n := range darwinUsers() {
if n == name {
return true
}
}
}
return false
}
func defaultShell() string {
if _, err := os.Stat("/bin/bash"); err == nil {
return "/bin/bash"
}
return "/bin/sh"
}
// sysUsers reports who the agent is and which users a terminal can be opened as.
func sysUsers() proto.SysUsersResult {
cur := currentUser()
res := proto.SysUsersResult{Current: cur, CanSwitch: os.Geteuid() == 0, Users: []string{cur}}
if !res.CanSwitch {
return res
}
seen := map[string]bool{cur: true}
for _, n := range loginUsers() {
if !seen[n] && validUserName(n) {
seen[n] = true
res.Users = append(res.Users, n)
}
}
sort.Strings(res.Users[1:])
return res
}
// loginUsers lists root plus the regular (non-system) accounts with a real shell.
func loginUsers() []string {
if runtime.GOOS == "darwin" {
return darwinUsers()
}
b, err := os.ReadFile("/etc/passwd")
if err != nil {
return nil
}
var out []string
for _, line := range strings.Split(string(b), "\n") {
// name:passwd:uid:gid:gecos:home:shell
f := strings.Split(line, ":")
if len(f) < 7 || strings.HasPrefix(line, "#") {
continue
}
uid, err := strconv.Atoi(f[2])
if err != nil || (uid != 0 && (uid < 1000 || uid == 65534)) {
continue
}
if sh := f[6]; strings.HasSuffix(sh, "/nologin") || strings.HasSuffix(sh, "/false") {
continue
}
out = append(out, f[0])
}
return out
}
func darwinUsers() []string {
b, err := exec.Command("dscl", ".", "-list", "/Users", "UniqueID").Output()
if err != nil {
return nil
}
var out []string
for _, line := range strings.Split(string(b), "\n") {
f := strings.Fields(line)
if len(f) != 2 {
continue
}
if uid, err := strconv.Atoi(f[1]); err == nil && (uid == 0 || uid >= 500) {
out = append(out, f[0])
}
}
return out
}
// addUser creates an OS user with a home directory, optionally sets its
// password and adds it to the sudo group.
func addUser(a proto.SysUserAddArgs) error {
if runtime.GOOS != "linux" {
return errors.New("создание пользователей поддерживается только на Linux")
}
if os.Geteuid() != 0 {
return errors.New("агент запущен не от root и не может создавать пользователей")
}
if !newUserNameRe.MatchString(a.Name) {
return errors.New("недопустимое имя: строчные латинские буквы, цифры, '_' и '-', до 32 символов")
}
if strings.ContainsAny(a.Password, "\r\n") {
return errors.New("пароль не должен содержать перевод строки")
}
if userExists(a.Name) {
return fmt.Errorf("пользователь %s уже существует", a.Name)
}
// Resolve the sudo group up front so we don't leave a half-configured user.
group := ""
if a.Sudo {
if group = sudoGroup(); group == "" {
return errors.New("на машине нет группы sudo или wheel")
}
}
switch {
case hasCmd("useradd"):
if err := run(nil, "useradd", "-m", "-s", defaultShell(), a.Name); err != nil {
return err
}
case hasCmd("adduser"): // BusyBox / Alpine
if err := run(nil, "adduser", "-D", "-s", defaultShell(), a.Name); err != nil {
return err
}
default:
return errors.New("не найдены useradd/adduser")
}
if a.Password != "" {
if err := run([]byte(a.Name+":"+a.Password+"\n"), "chpasswd"); err != nil {
return fmt.Errorf("пользователь создан, но пароль не задан: %w", err)
}
}
if group != "" {
var err error
switch {
case hasCmd("usermod"):
err = run(nil, "usermod", "-aG", group, a.Name)
case hasCmd("gpasswd"):
err = run(nil, "gpasswd", "-a", a.Name, group)
default: // BusyBox: adduser USER GROUP
err = run(nil, "adduser", a.Name, group)
}
if err != nil {
return fmt.Errorf("пользователь создан, но не добавлен в %s: %w", group, err)
}
}
return nil
}
// sudoGroup returns the group that grants sudo on this distro, or "".
func sudoGroup() string {
for _, g := range []string{"sudo", "wheel"} {
if _, err := user.LookupGroup(g); err == nil {
return g
}
}
return ""
}
func hasCmd(name string) bool {
_, err := exec.LookPath(name)
return err == nil
}
// run executes a command (optionally feeding stdin) and folds its output into
// the error so the panel can show why it failed.
func run(stdin []byte, name string, args ...string) error {
cmd := exec.Command(name, args...)
if stdin != nil {
cmd.Stdin = bytes.NewReader(stdin)
}
out, err := cmd.CombinedOutput()
if err != nil {
if msg := strings.TrimSpace(string(out)); msg != "" {
return fmt.Errorf("%s: %s", name, msg)
}
return fmt.Errorf("%s: %w", name, err)
}
return nil
}
+42
View File
@@ -27,6 +27,7 @@ type EnrollRequest struct {
OS string `json:"os"`
Arch string `json:"arch"`
Version string `json:"version"`
User string `json:"user,omitempty"` // OS user the agent runs as
}
// EnrollResponse carries the identity the agent persists and uses from then on.
@@ -41,6 +42,7 @@ type ControlMessage struct {
SessionID string `json:"session_id,omitempty"`
Port int `json:"port,omitempty"` // CtrlOpenForward: client-side port to dial
TaskID string `json:"task_id,omitempty"` // CtrlTask
User string `json:"user,omitempty"` // CtrlOpenTerminal: OS user for the shell ("" = the agent's own)
}
// Terminal / stream frame types on the session "in" channel (server -> agent).
@@ -85,8 +87,16 @@ const (
TaskFSDownload = "fs.download" // read a whole file (larger cap) for browser download
TaskSysUpdate = "sys.update" // download the latest agent binary and restart
TaskSysUsers = "sys.users" // report the agent's user and the login-capable users
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
// file be edited only when it fits, so that a save never cuts it short.
const FSReadCap = 5 * 1024 * 1024
// TaskEnvelope is what the agent fetches for a task id.
type TaskEnvelope struct {
Type string `json:"type"`
@@ -144,3 +154,35 @@ type FSReadResult struct {
Truncated bool `json:"truncated"`
Binary bool `json:"binary"`
}
// SysUsersResult is returned for TaskSysUsers.
type SysUsersResult struct {
Current string `json:"current"` // user the agent runs as
CanSwitch bool `json:"can_switch"` // agent is root, so it can open shells as (and create) other users
Users []string `json:"users"`
}
// SysUserAddArgs are the arguments of TaskSysUserAdd.
type SysUserAddArgs struct {
Name string `json:"name"`
Password string `json:"password,omitempty"` // empty leaves the account without a password
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"`
}
// 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"`
}
+12 -1
View File
@@ -24,6 +24,16 @@ func (s *Server) authenticateAgent(r *http.Request) *Agent {
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 {
h := r.Header.Get("Authorization")
if strings.HasPrefix(h, "Bearer ") {
@@ -34,7 +44,7 @@ func bearerToken(r *http.Request) string {
// enroll creates a new agent record for a valid enroll secret and returns the
// generated id plus a one-time plaintext token (only the hash is stored).
func (s *Server) enroll(name, hostname, os, arch, version string) (id, token string, err error) {
func (s *Server) enroll(name, hostname, os, arch, version, runUser string) (id, token string, err error) {
id = randToken()[:16]
token = randToken()
a := Agent{
@@ -44,6 +54,7 @@ func (s *Server) enroll(name, hostname, os, arch, version string) (id, token str
OS: os,
Arch: arch,
Version: version,
RunUser: runUser,
TokenHash: hashToken(token),
}
if err = s.db.CreateAgent(a); err != nil {
+26 -3
View File
@@ -3,6 +3,9 @@ package server
import (
"crypto/rand"
"encoding/hex"
"strconv"
lpra "github.com/x-grk/longpolling-remote-access"
)
// Config keys stored in the config table.
@@ -12,15 +15,17 @@ const (
cfgSetupDone = "setup_done"
cfgInstallCode = "install_code"
cfgUfwEnabled = "ufw_enabled"
cfgTunnelHost = "tunnel_host"
cfgTunnelPort = "tunnel_port"
)
// DefaultHostAddress is the public URL agents dial by default. It is baked into
// the rendered install.sh and can be changed from the settings page.
const DefaultHostAddress = "https://REDACTED:8445"
// Version is the server build version, injected at compile time via -ldflags
// (-X .../internal/server.Version=...). It is the newest version agents update to.
var Version = "dev"
// Version is the server build version (see VERSION.txt). It is the newest
// version agents update to.
var Version = lpra.Version
// ensureDefaults seeds the host address and a random enroll secret on first boot.
func (db *DB) ensureDefaults() error {
@@ -86,6 +91,24 @@ func (db *DB) SetUfwEnabled(on bool) error {
return db.SetConfig(cfgUfwEnabled, "0")
}
// TunnelHost is the host agents dial for the direct tunnel. Empty means the
// host of the URL they already reach the panel on.
func (db *DB) TunnelHost() string {
v, _ := db.GetConfig(cfgTunnelHost)
return v
}
// TunnelPort is the direct-tunnel port set in Settings, or 0 when it is left
// at the port of the server's -tunnel address.
func (db *DB) TunnelPort() int {
v, _ := db.GetConfig(cfgTunnelPort)
n, _ := strconv.Atoi(v)
if n < 1 || n > 65535 {
return 0
}
return n
}
func (db *DB) SetupDone() bool {
v, _ := db.GetConfig(cfgSetupDone)
return v == "1"
+43 -21
View File
@@ -59,6 +59,8 @@ CREATE TABLE IF NOT EXISTS agents (
arch TEXT NOT NULL,
token_hash TEXT NOT NULL,
version TEXT NOT NULL DEFAULT '',
run_user TEXT NOT NULL DEFAULT '',
login_user TEXT NOT NULL DEFAULT '',
enrolled_at INTEGER NOT NULL,
last_seen INTEGER NOT NULL
);
@@ -101,6 +103,9 @@ CREATE TABLE IF NOT EXISTS forward_rules (
`ALTER TABLE forward_rules ADD COLUMN proto TEXT NOT NULL DEFAULT 'http'`,
`ALTER TABLE agents ADD COLUMN version TEXT NOT NULL DEFAULT ''`,
`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 login_user TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE forward_rules ADD COLUMN no_ufw INTEGER NOT NULL DEFAULT 0`,
} {
if _, err := db.sql.Exec(alter); err != nil && !strings.Contains(err.Error(), "duplicate column") {
return err
@@ -222,20 +227,30 @@ type Agent struct {
Arch string
TokenHash string
Version string
RunUser string // OS user the agent process runs as (reported by the agent)
LoginUser string // OS user terminals are opened as; "" = RunUser
LastSeen time.Time
}
// Online reports whether the agent polled recently enough to be considered up.
func (a Agent) Online() bool {
// TerminalUser is the OS user a terminal on this agent runs as. Agents are
// installed as a root systemd service, so that is the fallback for agents too
// old to report their user.
func (a Agent) TerminalUser() string {
return firstNonEmpty(a.LoginUser, a.RunUser, "root")
}
// recentlySeen is the fallback for "online" right after a server start, while
// 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
}
func (db *DB) CreateAgent(a Agent) error {
now := time.Now().Unix()
_, err := db.sql.Exec(
`INSERT INTO agents(id, name, hostname, os, arch, token_hash, version, enrolled_at, last_seen)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)`,
a.ID, a.Name, a.Hostname, a.OS, a.Arch, a.TokenHash, a.Version, now, now)
`INSERT INTO agents(id, name, hostname, os, arch, token_hash, version, run_user, enrolled_at, last_seen)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
a.ID, a.Name, a.Hostname, a.OS, a.Arch, a.TokenHash, a.Version, a.RunUser, now, now)
return err
}
@@ -243,8 +258,8 @@ func (db *DB) GetAgent(id string) (*Agent, error) {
a := &Agent{}
var last int64
err := db.sql.QueryRow(
`SELECT id, name, hostname, os, arch, token_hash, version, last_seen FROM agents WHERE id = ?`, id).
Scan(&a.ID, &a.Name, &a.Hostname, &a.OS, &a.Arch, &a.TokenHash, &a.Version, &last)
`SELECT id, name, hostname, os, arch, token_hash, version, run_user, login_user, last_seen FROM agents WHERE id = ?`, id).
Scan(&a.ID, &a.Name, &a.Hostname, &a.OS, &a.Arch, &a.TokenHash, &a.Version, &a.RunUser, &a.LoginUser, &last)
if err == sql.ErrNoRows {
return nil, nil
}
@@ -257,7 +272,7 @@ func (db *DB) GetAgent(id string) (*Agent, error) {
func (db *DB) ListAgents() ([]Agent, error) {
rows, err := db.sql.Query(
`SELECT id, name, hostname, os, arch, version, last_seen FROM agents ORDER BY name`)
`SELECT id, name, hostname, os, arch, version, run_user, login_user, last_seen FROM agents ORDER BY name`)
if err != nil {
return nil, err
}
@@ -266,7 +281,7 @@ func (db *DB) ListAgents() ([]Agent, error) {
for rows.Next() {
var a Agent
var last int64
if err := rows.Scan(&a.ID, &a.Name, &a.Hostname, &a.OS, &a.Arch, &a.Version, &last); err != nil {
if err := rows.Scan(&a.ID, &a.Name, &a.Hostname, &a.OS, &a.Arch, &a.Version, &a.RunUser, &a.LoginUser, &last); err != nil {
return nil, err
}
a.LastSeen = time.Unix(last, 0)
@@ -280,10 +295,16 @@ func (db *DB) TouchAgent(id string) error {
return err
}
// TouchAgentVersion updates last_seen and the reported version together.
func (db *DB) TouchAgentVersion(id, version string) error {
_, err := db.sql.Exec(`UPDATE agents SET last_seen = ?, version = ? WHERE id = ?`,
time.Now().Unix(), version, id)
// TouchAgentInfo updates last_seen together with the reported version and OS user.
func (db *DB) TouchAgentInfo(id, version, runUser string) error {
_, err := db.sql.Exec(`UPDATE agents SET last_seen = ?, version = ?, run_user = ? WHERE id = ?`,
time.Now().Unix(), version, runUser, id)
return err
}
// SetAgentLoginUser sets the OS user terminals are opened as ("" = the agent's own).
func (db *DB) SetAgentLoginUser(id, user string) error {
_, err := db.sql.Exec(`UPDATE agents SET login_user = ? WHERE id = ?`, user, id)
return err
}
@@ -308,6 +329,7 @@ type ForwardRule struct {
HostPort int
ListenAddr string
Proto string
NoUfw bool // the panel opens no ufw rule for this forward
}
func (db *DB) CreateForwardRule(r ForwardRule) (int64, error) {
@@ -315,9 +337,9 @@ func (db *DB) CreateForwardRule(r ForwardRule) (int64, error) {
r.Proto = "http"
}
res, err := db.sql.Exec(
`INSERT INTO forward_rules(agent_id, client_port, host_port, listen_addr, proto, created_at)
VALUES(?, ?, ?, ?, ?, ?)`,
r.AgentID, r.ClientPort, r.HostPort, r.ListenAddr, r.Proto, time.Now().Unix())
`INSERT INTO forward_rules(agent_id, client_port, host_port, listen_addr, proto, no_ufw, created_at)
VALUES(?, ?, ?, ?, ?, ?, ?)`,
r.AgentID, r.ClientPort, r.HostPort, r.ListenAddr, r.Proto, r.NoUfw, time.Now().Unix())
if err != nil {
return 0, err
}
@@ -326,7 +348,7 @@ func (db *DB) CreateForwardRule(r ForwardRule) (int64, error) {
func (db *DB) ListForwardRules() ([]ForwardRule, error) {
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 FROM forward_rules ORDER BY id`)
if err != nil {
return nil, err
}
@@ -334,7 +356,7 @@ func (db *DB) ListForwardRules() ([]ForwardRule, error) {
var out []ForwardRule
for rows.Next() {
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); err != nil {
return nil, err
}
out = append(out, r)
@@ -359,8 +381,8 @@ func (db *DB) ListForwardRulesByAgent(agentID string) ([]ForwardRule, error) {
func (db *DB) GetForwardRule(id int64) (*ForwardRule, error) {
r := &ForwardRule{}
err := db.sql.QueryRow(
`SELECT id, agent_id, client_port, host_port, listen_addr, proto FROM forward_rules WHERE id = ?`, id).
Scan(&r.ID, &r.AgentID, &r.ClientPort, &r.HostPort, &r.ListenAddr, &r.Proto)
`SELECT id, agent_id, client_port, host_port, listen_addr, proto, no_ufw FROM forward_rules WHERE id = ?`, id).
Scan(&r.ID, &r.AgentID, &r.ClientPort, &r.HostPort, &r.ListenAddr, &r.Proto, &r.NoUfw)
if err == sql.ErrNoRows {
return nil, nil
}
@@ -378,7 +400,7 @@ type UfwRule struct {
ID int64
Port int
Proto string
RuleID int64 // bound forward rule id, or 0 for shared/manual (e.g. tunnel 8446)
RuleID int64 // bound forward rule id, or 0 for shared/manual (e.g. the tunnel port)
}
func (db *DB) AddUfwRule(port int, proto string, ruleID int64) error {
+4 -4
View File
@@ -14,8 +14,8 @@ import (
// supports three transports:
// - "http": tunneled over the HTTP long-poll channel (works anywhere nginx
// reaches, highest latency);
// - "tcp": direct TCP multiplexed over the 8446 tunnel (low latency);
// - "udp": direct UDP over the 8446 channel.
// - "tcp": direct TCP multiplexed over the tunnel port (low latency);
// - "udp": direct UDP over the tunnel port.
type forwardManager struct {
hub *Hub
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) {
at := m.tun.get(rule.AgentID)
if at == nil {
// No direct tunnel (agent offline or 8446 unreachable).
// No direct tunnel (agent offline or the tunnel port unreachable).
conn.Close()
return
}
+43 -12
View File
@@ -16,7 +16,7 @@ func writeJSON(w http.ResponseWriter, v any) {
_ = 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) {
secret := r.Header.Get("X-Enroll-Secret")
if !constantTimeEqual(secret, s.db.EnrollSecret()) {
@@ -28,7 +28,7 @@ func (s *Server) handleEnroll(w http.ResponseWriter, r *http.Request) {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
id, token, err := s.enroll(req.Name, req.Hostname, req.OS, req.Arch, req.Version)
id, token, err := s.enroll(req.Name, req.Hostname, req.OS, req.Arch, req.Version, req.User)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
@@ -36,23 +36,50 @@ func (s *Server) handleEnroll(w http.ResponseWriter, r *http.Request) {
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) {
a := s.authenticateAgent(r)
if a == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
// The agent sends its version occasionally; record it when present.
if v := r.Header.Get("X-Agent-Version"); v != "" && v != a.Version {
_ = s.db.TouchAgentVersion(a.ID, v)
// The agent sends its version and OS user occasionally; record them when
// present. Agents older than 0.0.2 send no user, so keep what we have.
v := r.Header.Get("X-Agent-Version")
u := firstNonEmpty(r.Header.Get("X-Agent-User"), a.RunUser)
if v != "" && (v != a.Version || u != a.RunUser) {
_ = s.db.TouchAgentInfo(a.ID, v, u)
} else {
_ = s.db.TouchAgent(a.ID)
}
writeJSON(w, 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.
s.setTunnelHeader(w)
writeJSON(w, msg)
}
// GET /api/agent/task/{id} — the agent fetches the task to run.
// tunnelAddrHeader carries the direct-tunnel address ("host:port", host empty
// = the panel's host) to agents, on every poll and on /api/agent/config.
const tunnelAddrHeader = "X-Tunnel-Addr"
func (s *Server) setTunnelHeader(w http.ResponseWriter) {
if addr := s.tunnelAdvert(); addr != "" {
w.Header().Set(tunnelAddrHeader, addr)
}
}
// GET /api/agent/config - settings an agent picks up when it connects, without
// waiting out a poll hold.
func (s *Server) handleAgentConfig(w http.ResponseWriter, r *http.Request) {
if s.authenticateAgent(r) == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
s.setTunnelHeader(w)
w.WriteHeader(http.StatusNoContent)
}
// GET /api/agent/task/{id} - the agent fetches the task to run.
func (s *Server) handleTaskFetch(w http.ResponseWriter, r *http.Request) {
if s.authenticateAgent(r) == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
@@ -66,7 +93,7 @@ func (s *Server) handleTaskFetch(w http.ResponseWriter, r *http.Request) {
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) {
if s.authenticateAgent(r) == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
@@ -81,7 +108,7 @@ func (s *Server) handleTaskResult(w http.ResponseWriter, r *http.Request) {
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) {
if s.authenticateAgent(r) == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
@@ -100,7 +127,7 @@ func (s *Server) handleTermIn(w http.ResponseWriter, r *http.Request) {
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) {
if s.authenticateAgent(r) == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
@@ -121,7 +148,11 @@ func (s *Server) handleTermOut(w http.ResponseWriter, r *http.Request) {
return
}
if msg.Exit {
s.hub.CloseSession(sess.ID)
// End the stream now, but keep the session reachable for a moment so
// the panel can still collect the last output (e.g. why the shell
// failed to start).
sess.close()
time.AfterFunc(pollHold, func() { s.hub.CloseSession(sess.ID) })
}
w.WriteHeader(http.StatusNoContent)
}
+242 -4
View File
@@ -1,9 +1,14 @@
package server
import (
"encoding/json"
"errors"
"fmt"
"log"
"net"
"net/http"
"net/url"
"slices"
"strconv"
"strings"
"time"
@@ -113,7 +118,8 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
}
rows := make([]row, 0, len(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{
"Admin": s.currentAdmin(r),
@@ -122,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) {
id := r.PathValue("id")
a, err := s.db.GetAgent(id)
@@ -141,7 +177,15 @@ func (s *Server) handleAgentDelete(w http.ResponseWriter, r *http.Request) {
return
}
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.stats.drop(id)
s.hub.Forget(id)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
@@ -152,13 +196,135 @@ func (s *Server) handleAgentEdit(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
s.render(w, "edit.html", map[string]any{
data := map[string]any{
"Agent": a,
"CSRF": csrfToken(r),
"ServerVersion": Version,
"Outdated": a.Version != "" && a.Version != Version,
"Updated": r.URL.Query().Get("updated") == "1",
"UserOK": r.URL.Query().Get("uok"),
"UserErr": r.URL.Query().Get("uerr"),
}
// The user list lives on the machine, so it is only known while it is online.
if s.online(a) {
if users, err := s.agentUsers(id); err == nil {
data["Users"] = users
} else {
data["UsersErr"] = err.Error()
}
}
s.render(w, "edit.html", data)
}
// usersTaskTimeout bounds how long the edit page waits for the agent.
const usersTaskTimeout = 8 * time.Second
// agentUsers asks the agent who it runs as and which users it can log in as.
func (s *Server) agentUsers(agentID string) (*proto.SysUsersResult, error) {
taskID, ch := s.hub.CreateTask(agentID, proto.TaskSysUsers, nil)
defer s.hub.DiscardTask(taskID)
select {
case res := <-ch:
if !res.OK {
// Agents older than 0.0.2 don't know this task.
return nil, errors.New("клиент не поддерживает выбор пользователя - обновите его")
}
var out proto.SysUsersResult
if err := json.Unmarshal(res.Data, &out); err != nil {
return nil, errors.New("некорректный ответ клиента")
}
return &out, nil
case <-time.After(usersTaskTimeout):
return nil, errors.New("таймаут ожидания клиента")
}
}
// editUserRedirect returns to the edit page with a result banner.
func editUserRedirect(w http.ResponseWriter, r *http.Request, id, key, msg string) {
http.Redirect(w, r, "/agents/"+id+"/edit?"+key+"="+url.QueryEscape(msg), http.StatusSeeOther)
}
// handleAgentSetUser picks the OS user terminals on this machine run as.
func (s *Server) handleAgentSetUser(w http.ResponseWriter, r *http.Request) {
if !checkCSRF(r) {
http.Error(w, "bad csrf", http.StatusForbidden)
return
}
id := r.PathValue("id")
a, err := s.db.GetAgent(id)
if err != nil || a == nil {
http.NotFound(w, r)
return
}
name := strings.TrimSpace(r.FormValue("user"))
if !s.online(a) {
editUserRedirect(w, r, id, "uerr", "Машина офлайн - сменить пользователя нельзя.")
return
}
// Only accept a user the machine itself reports, so a typo (or an agent too
// old to honour the choice) can't silently leave the terminal as root.
users, err := s.agentUsers(id)
if err != nil {
editUserRedirect(w, r, id, "uerr", err.Error())
return
}
if !slices.Contains(users.Users, name) {
editUserRedirect(w, r, id, "uerr", "Пользователя "+name+" нет на машине.")
return
}
if name == users.Current {
name = "" // follow the agent's own user
}
if err := s.db.SetAgentLoginUser(id, name); err != nil {
editUserRedirect(w, r, id, "uerr", "Не удалось сохранить.")
return
}
if admin := s.currentAdmin(r); admin != nil {
s.db.Audit(admin.ID, id, "set_user:"+firstNonEmpty(name, users.Current))
}
editUserRedirect(w, r, id, "uok", "Терминал будет открываться от "+firstNonEmpty(name, users.Current)+".")
}
// handleAgentAddUser creates a new OS user on the machine and switches the
// terminal to it.
func (s *Server) handleAgentAddUser(w http.ResponseWriter, r *http.Request) {
if !checkCSRF(r) {
http.Error(w, "bad csrf", http.StatusForbidden)
return
}
id := r.PathValue("id")
a, err := s.db.GetAgent(id)
if err != nil || a == nil {
http.NotFound(w, r)
return
}
if !s.online(a) {
editUserRedirect(w, r, id, "uerr", "Машина офлайн - создать пользователя нельзя.")
return
}
name := strings.TrimSpace(r.FormValue("name"))
args, _ := json.Marshal(proto.SysUserAddArgs{
Name: name,
Password: r.FormValue("password"),
Sudo: r.FormValue("sudo") == "1",
})
taskID, ch := s.hub.CreateTask(id, proto.TaskSysUserAdd, args)
defer s.hub.DiscardTask(taskID)
select {
case res := <-ch:
if !res.OK {
editUserRedirect(w, r, id, "uerr", "Не удалось создать пользователя: "+res.Error)
return
}
case <-time.After(30 * time.Second):
editUserRedirect(w, r, id, "uerr", "Таймаут ожидания клиента.")
return
}
_ = s.db.SetAgentLoginUser(id, name)
if admin := s.currentAdmin(r); admin != nil {
s.db.Audit(admin.ID, id, "create_user:"+name)
}
editUserRedirect(w, r, id, "uok", "Пользователь "+name+" создан и выбран для терминала.")
}
// handleAgentUpdate asks the agent to download the latest binary and restart.
@@ -173,7 +339,7 @@ func (s *Server) handleAgentUpdate(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
if !a.Online() {
if !s.online(a) {
http.Redirect(w, r, "/agents/"+id+"/edit?updated=0", http.StatusSeeOther)
return
}
@@ -223,9 +389,76 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
"UfwRunnable": ufwRunnable,
"UfwEnabled": s.db.UfwEnabled(),
"UfwRules": ufwRules,
"UfwError": s.ufw.LastError(),
"TunnelPort": s.TunnelPort(),
"TunnelDefault": s.tunnelDefault,
"TunnelHost": s.db.TunnelHost(),
"TunnelCustom": s.db.TunnelPort() > 0,
"Error": map[string]string{
"tunnel_host": "Некорректный хост туннеля: нужен IP или имя без схемы и порта.",
"tunnel_port": "Некорректный порт туннеля.",
"tunnel_listen": "Не удалось занять новый порт туннеля на хосте (возможно, уже используется). Порт не изменён.",
}[r.URL.Query().Get("err")],
})
}
// POST /settings/tunnel - host and port agents use for the direct tunnel.
func (s *Server) handleSetTunnel(w http.ResponseWriter, r *http.Request) {
if !checkCSRF(r) {
http.Error(w, "bad csrf", http.StatusForbidden)
return
}
if s.tunnelDefault == 0 {
http.Redirect(w, r, "/settings", http.StatusSeeOther)
return
}
host := strings.TrimSpace(r.FormValue("tunnel_host"))
host = strings.TrimSuffix(strings.TrimPrefix(host, "["), "]")
if !validTunnelHost(host) {
http.Redirect(w, r, "/settings?err=tunnel_host", http.StatusSeeOther)
return
}
// An empty port goes back to the one the server was started with.
port, custom := s.tunnelDefault, ""
if v := strings.TrimSpace(r.FormValue("tunnel_port")); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n < 1 || n > 65535 {
http.Redirect(w, r, "/settings?err=tunnel_port", http.StatusSeeOther)
return
}
port = n
if n != s.tunnelDefault {
custom = v
}
}
if err := s.setTunnelPort(port); err != nil {
log.Printf("tunnel: move to port %d: %v", port, err)
http.Redirect(w, r, "/settings?err=tunnel_listen", http.StatusSeeOther)
return
}
_ = s.db.SetConfig(cfgTunnelPort, custom)
_ = s.db.SetConfig(cfgTunnelHost, host)
http.Redirect(w, r, "/settings", http.StatusSeeOther)
}
// validTunnelHost accepts an empty value, an IP, or a bare host name.
func validTunnelHost(host string) bool {
if host == "" || net.ParseIP(host) != nil {
return true
}
if len(host) > 253 {
return false
}
for _, c := range host {
switch {
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '-', c == '.', c == '_':
default:
return false
}
}
return true
}
func (s *Server) handleSetHost(w http.ResponseWriter, r *http.Request) {
if !checkCSRF(r) {
http.Error(w, "bad csrf", http.StatusForbidden)
@@ -282,7 +515,12 @@ func (s *Server) handleUfwToggle(w http.ResponseWriter, r *http.Request) {
http.Error(w, "bad csrf", http.StatusForbidden)
return
}
_ = s.db.SetUfwEnabled(r.FormValue("enabled") == "1")
on := r.FormValue("enabled") == "1"
_ = s.db.SetUfwEnabled(on)
if on {
// Open what forwards created while the integration was off need.
s.ufw.sync()
}
http.Redirect(w, r, "/settings", http.StatusSeeOther)
}
+5 -5
View File
@@ -34,10 +34,10 @@ func (s *Server) handleFilesPage(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
s.render(w, "files.html", map[string]any{"Agent": a})
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
// forwarded verbatim to the agent.
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)
return
}
if !a.Online() {
if !s.online(a) {
writeJSON(w, proto.TaskResult{Error: "машина офлайн"})
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) {
agentID := r.PathValue("id")
p := r.URL.Query().Get("path")
@@ -83,7 +83,7 @@ func (s *Server) handleFSDownload(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
if !a.Online() {
if !s.online(a) {
http.Error(w, "машина офлайн", http.StatusServiceUnavailable)
return
}
+37 -6
View File
@@ -3,6 +3,7 @@ package server
import (
"log"
"net/http"
"net/url"
"strconv"
"strings"
)
@@ -17,6 +18,7 @@ func (s *Server) handleForwardsPage(w http.ResponseWriter, r *http.Request) {
rules, _ := s.db.ListForwardRulesByAgent(id)
errMsg := map[string]string{
"busy": "Этот порт хоста уже занят другим правилом.",
"tunnel": "Этот порт занят прямым туннелем панели (см. Настройки).",
"port": "Некорректный номер порта.",
"listen": "Не удалось занять порт на хосте (возможно, уже используется).",
"db": "Ошибка базы данных.",
@@ -26,9 +28,27 @@ func (s *Server) handleForwardsPage(w http.ResponseWriter, r *http.Request) {
"Rules": rules,
"CSRF": csrfToken(r),
"Error": errMsg,
"TunnelPort": s.TunnelPort(),
"PanelPort": panelPort(s.db.HostAddress()),
"UfwEnabled": s.ufw.enabled(),
})
}
// panelPort is the port of the panel URL agents dial, for the mode hints.
func panelPort(host string) string {
u, err := url.Parse(host)
if err != nil {
return ""
}
if p := u.Port(); p != "" {
return p
}
if u.Scheme == "http" {
return "80"
}
return "443"
}
// POST /agents/{id}/forwards/add
func (s *Server) handleForwardAdd(w http.ResponseWriter, r *http.Request) {
if !checkCSRF(r) {
@@ -57,12 +77,17 @@ func (s *Server) handleForwardAdd(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/agents/"+id+"/forwards?err=port", http.StatusSeeOther)
return
}
// Reject a host port already bound by another forward rule.
// Reject a host port already bound by another forward rule or the tunnel.
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)
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") != ""}
ruleID, err := s.db.CreateForwardRule(rule)
if err != nil {
http.Redirect(w, r, "/agents/"+id+"/forwards?err=db", http.StatusSeeOther)
@@ -89,13 +114,19 @@ func (s *Server) handleForwardDelete(w http.ResponseWriter, r *http.Request) {
}
rid, _ := strconv.ParseInt(r.PathValue("rid"), 10, 64)
rule, _ := s.db.GetForwardRule(rid)
s.fwd.close(rid)
_ = s.db.DeleteForwardRule(rid)
// Remove the ufw rule bound to this forward rule (shared 8446 stays).
s.ufw.revokeForRule(rid)
s.removeForward(rid)
dest := "/"
if rule != nil {
dest = "/agents/" + rule.AgentID + "/forwards"
}
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)
}
+193
View File
@@ -0,0 +1,193 @@
package server
import (
"crypto/rand"
"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
// speedTaskTimeout covers the agent's ping + down + up windows with slack.
speedTaskTimeout = 30 * time.Second
// speedDownCap bounds one download stream; speedDownFor ends it if the
// agent does not hang up first.
speedDownCap = 256 << 20
speedDownFor = 6 * time.Second
speedUpCap = 64 << 20
)
// 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})
}
// runTask runs a one-shot task on the agent and waits for its result.
func (s *Server) runTask(agentID, typ string, timeout time.Duration) (proto.TaskResult, error) {
taskID, ch := s.hub.CreateTask(agentID, typ, nil)
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, 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.
func (s *Server) handleAgentSpeed(w http.ResponseWriter, r *http.Request) {
id, ok := s.onlineAgent(w, r)
if !ok {
return
}
res, err := s.runTask(id, proto.TaskSysSpeed, speedTaskTimeout)
if err != nil {
writeJSON(w, proto.TaskResult{Error: err.Error()})
return
}
writeJSON(w, oldAgentError(res))
}
// 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
})
// 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) {
if s.authenticateAgent(r) == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
left := speedDownCap
if v := r.URL.Query().Get("bytes"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 && n < left {
left = n
}
}
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")
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
}
left -= n
}
}
// POST /api/agent/speed/up - swallow the agent's upload.
func (s *Server) handleSpeedUp(w http.ResponseWriter, r *http.Request) {
if s.authenticateAgent(r) == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
_, _ = io.Copy(io.Discard, http.MaxBytesReader(w, r.Body, speedUpCap))
w.WriteHeader(http.StatusNoContent)
}
+6 -6
View File
@@ -12,7 +12,7 @@ import (
// same transport the agent uses), so it works through any reverse proxy without
// 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) {
agentID := r.PathValue("agentID")
agent, err := s.db.GetAgent(agentID)
@@ -21,14 +21,14 @@ func (s *Server) handlePanelOpen(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
sess := s.hub.CreateSession(agentID)
sess := s.hub.CreateSession(agentID, agent.LoginUser)
if admin := s.currentAdmin(r); admin != nil {
s.db.Audit(admin.ID, agentID, "open_terminal")
s.db.Audit(admin.ID, agentID, "open_terminal:"+agent.TerminalUser())
}
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) {
sess := s.hub.Session(r.PathValue("sid"))
if sess == nil {
@@ -51,7 +51,7 @@ type panelInMsg struct {
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) {
sess := s.hub.Session(r.PathValue("sid"))
if sess == nil {
@@ -72,7 +72,7 @@ func (s *Server) handlePanelIn(w http.ResponseWriter, r *http.Request) {
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) {
s.hub.CloseSession(r.PathValue("sid"))
w.WriteHeader(http.StatusNoContent)
+74 -7
View File
@@ -22,6 +22,12 @@ type Hub struct {
sessions map[string]*Session
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.
@@ -31,7 +37,7 @@ type taskEntry struct {
}
// 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 {
ID string
AgentID string
@@ -52,6 +58,9 @@ func NewHub() *Hub {
ctrlWaiters: map[string]chan struct{}{},
sessions: map[string]*Session{},
tasks: map[string]*taskEntry{},
polls: map[string]int{},
lastPoll: map[string]time.Time{},
started: time.Now(),
}
}
@@ -82,10 +91,11 @@ func (h *Hub) newSession(agentID string) *Session {
return s
}
// CreateSession registers a terminal session and queues it for the agent.
func (h *Hub) CreateSession(agentID string) *Session {
// CreateSession registers a terminal session and queues it for the agent. user
// is the OS user to run the shell as ("" = the agent's own user).
func (h *Hub) CreateSession(agentID, user string) *Session {
s := h.newSession(agentID)
h.enqueueJob(agentID, proto.ControlMessage{Type: proto.CtrlOpenTerminal, SessionID: s.ID})
h.enqueueJob(agentID, proto.ControlMessage{Type: proto.CtrlOpenTerminal, SessionID: s.ID, User: user})
return s
}
@@ -96,9 +106,56 @@ func (h *Hub) CreateForward(agentID string, port int) *Session {
return s
}
// WaitControl blocks until a job is queued for agentID or timeout elapses.
func (h *Hub) WaitControl(agentID string, timeout time.Duration) proto.ControlMessage {
deadline := time.After(timeout)
// pollGrace is how long an agent stays online between two control polls: it
// re-polls at once, so this only has to cover a slow round trip or one retry.
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 {
h.mu.Lock()
if q := h.jobs[agentID]; len(q) > 0 {
@@ -118,6 +175,9 @@ func (h *Hub) WaitControl(agentID string, timeout time.Duration) proto.ControlMe
case <-w:
case <-deadline:
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}
}
}
}
@@ -245,8 +305,15 @@ func (s *Session) PollOut(timeout time.Duration) ([]proto.OutMessage, bool) {
case <-time.After(timeout):
return nil, true
case <-s.closed:
// Hand over output queued before the close (e.g. the reason a shell
// failed to start) instead of dropping it.
select {
case m := <-s.out:
batch = append(batch, m)
default:
return nil, false
}
}
for {
select {
case m := <-s.out:
+101 -15
View File
@@ -9,6 +9,8 @@ import (
"net/http"
"strconv"
"strings"
"sync"
"sync/atomic"
texttemplate "text/template"
)
@@ -24,7 +26,8 @@ type Options struct {
// nginx TLS); set false only for plain-HTTP local testing.
SecureCookies bool
// TunnelAddr is the listen address for the direct TCP+UDP forward tunnel
// (default ":8446"). Empty disables direct forwarding.
// Empty disables direct forwarding. The port can be overridden from the
// settings page.
TunnelAddr string
}
@@ -33,12 +36,24 @@ type Server struct {
db *DB
hub *Hub
fwd *forwardManager
tun *tunnelHub
udp *udpRelay
ufw *ufwManager
tmpl *template.Template
limiter *loginLimiter
distDir string
secureCookies bool
installTmpl *texttemplate.Template
// Direct tunnel: bind host and port of the -tunnel address, and the port
// currently listened on (the settings override, else the default).
// tunnelDefault 0 means the tunnel is disabled.
tunnelBind string
tunnelDefault int
tunnelMu sync.Mutex // serializes port changes
tunnelPort atomic.Int64
stats statCache // last load sample per agent, for the status page
}
// New builds the server, parsing embedded templates.
@@ -65,26 +80,81 @@ func New(opts Options) (*Server, error) {
db: opts.DB,
hub: hub,
fwd: newForwardManager(hub, tun, udp),
ufw: newUfwManager(opts.DB, portFromAddr(opts.TunnelAddr)),
tun: tun,
udp: udp,
tmpl: tmpl,
limiter: newLoginLimiter(),
distDir: opts.DistDir,
secureCookies: opts.SecureCookies,
installTmpl: installTmpl,
}
// Start the direct forward tunnel (TCP mux + UDP) on 8446.
s.ufw = newUfwManager(opts.DB, s.TunnelPort)
// Start the direct forward tunnel (TCP mux + UDP), on the port from the
// settings page if one is set there.
if opts.TunnelAddr != "" {
if err := tun.listen(opts.TunnelAddr); err != nil {
log.Printf("tunnel tcp listen %s: %v", opts.TunnelAddr, err)
s.tunnelBind, s.tunnelDefault = splitAddr(opts.TunnelAddr)
}
if err := udp.listen(opts.TunnelAddr); err != nil {
log.Printf("tunnel udp listen %s: %v", opts.TunnelAddr, err)
if s.tunnelDefault > 0 {
port := s.tunnelDefault
if p := s.db.TunnelPort(); p > 0 {
port = p
}
s.tunnelPort.Store(int64(port))
addr := s.tunnelListenAddr(port)
if err := tun.listen(addr); err != nil {
log.Printf("tunnel tcp listen %s: %v", addr, err)
}
if err := udp.listen(addr); err != nil {
log.Printf("tunnel udp listen %s: %v", addr, err)
}
}
s.restoreForwards()
go s.ufw.sync()
return s, nil
}
// TunnelPort is the port the direct tunnel listens on (0 = disabled).
func (s *Server) TunnelPort() int { return int(s.tunnelPort.Load()) }
func (s *Server) tunnelListenAddr(port int) string {
return net.JoinHostPort(s.tunnelBind, strconv.Itoa(port))
}
// tunnelAdvert is the tunnel address handed to agents as "host:port". The host
// is empty unless one is set in Settings; agents then use the panel's host.
func (s *Server) tunnelAdvert() string {
port := s.TunnelPort()
if port == 0 {
return ""
}
return net.JoinHostPort(s.db.TunnelHost(), strconv.Itoa(port))
}
// setTunnelPort moves the direct tunnel to another port. Connected agents are
// dropped and redial the new port once their next poll tells them about it.
func (s *Server) setTunnelPort(port int) error {
s.tunnelMu.Lock()
defer s.tunnelMu.Unlock()
old := s.TunnelPort()
if old == 0 || port == old {
return nil
}
addr := s.tunnelListenAddr(port)
if err := s.tun.listen(addr); err != nil {
return err
}
if err := s.udp.listen(addr); err != nil {
// Keep TCP and UDP on the same port: go back to the old one.
if rerr := s.tun.listen(s.tunnelListenAddr(old)); rerr != nil {
log.Printf("tunnel tcp listen %s: %v", s.tunnelListenAddr(old), rerr)
}
return err
}
s.tunnelPort.Store(int64(port))
go s.ufw.sync()
return nil
}
// restoreForwards re-opens the host listeners for all saved forward rules.
func (s *Server) restoreForwards() {
rules, err := s.db.ListForwardRules()
@@ -93,6 +163,12 @@ func (s *Server) restoreForwards() {
return
}
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 {
log.Printf("restore forward rule %d (%s:%d): %v", r.ID, r.ListenAddr, r.HostPort, err)
}
@@ -126,12 +202,16 @@ func (s *Server) Handler() http.Handler {
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}/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("POST /agents/{id}/rename", s.requireAdmin(s.handleAgentRename))
mux.Handle("POST /agents/{id}/update", s.requireAdmin(s.handleAgentUpdate))
mux.Handle("POST /agents/{id}/user", s.requireAdmin(s.handleAgentSetUser))
mux.Handle("POST /agents/{id}/users/add", s.requireAdmin(s.handleAgentAddUser))
mux.Handle("POST /agents/{id}/delete", s.requireAdmin(s.handleAgentDelete))
mux.Handle("GET /settings", s.requireAdmin(s.handleSettings))
mux.Handle("POST /settings/host", s.requireAdmin(s.handleSetHost))
mux.Handle("POST /settings/tunnel", s.requireAdmin(s.handleSetTunnel))
mux.Handle("POST /settings/secret/rotate", s.requireAdmin(s.handleRotateSecret))
mux.Handle("POST /settings/admins/add", s.requireAdmin(s.handleAddAdmin))
mux.Handle("POST /settings/admins/delete", s.requireAdmin(s.handleDeleteAdmin))
@@ -148,6 +228,11 @@ func (s *Server) Handler() http.Handler {
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))
// 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))
// Panel port forwarding (admin-only).
mux.Handle("POST /agents/{id}/forwards/add", s.requireAdmin(s.handleForwardAdd))
mux.Handle("POST /forwards/{rid}/delete", s.requireAdmin(s.handleForwardDelete))
@@ -155,10 +240,13 @@ func (s *Server) Handler() http.Handler {
// Agent API (token/secret auth inside handlers).
mux.HandleFunc("POST /api/agent/enroll", s.handleEnroll)
mux.HandleFunc("GET /api/agent/poll", s.handlePoll)
mux.HandleFunc("GET /api/agent/config", s.handleAgentConfig)
mux.HandleFunc("GET /api/agent/term/{sid}/in", s.handleTermIn)
mux.HandleFunc("POST /api/agent/term/{sid}/out", s.handleTermOut)
mux.HandleFunc("GET /api/agent/task/{id}", s.handleTaskFetch)
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)
return s.logRequests(mux)
}
@@ -200,17 +288,15 @@ func (s *Server) render(w http.ResponseWriter, name string, data map[string]any)
}
}
// portFromAddr extracts the numeric port from a "host:port" or ":port" address.
func portFromAddr(addr string) int {
if addr == "" {
return 0
}
_, p, err := net.SplitHostPort(addr)
// splitAddr splits a "host:port" or ":port" listen address. The port is 0 when
// the address has none.
func splitAddr(addr string) (host string, port int) {
h, p, err := net.SplitHostPort(addr)
if err != nil {
return 0
return "", 0
}
n, _ := strconv.Atoi(p)
return n
return h, n
}
func clientIP(r *http.Request) string {
+20 -1
View File
@@ -39,6 +39,7 @@ func readLine(r io.Reader, max int) (string, error) {
type tunnelHub struct {
db *DB
mu sync.Mutex
ln net.Listener
agents map[string]*agentTunnel
}
@@ -57,12 +58,30 @@ type agentTunnel struct {
streams map[uint32]net.Conn
}
// listen runs the 8446 TCP tunnel acceptor until the listener closes.
// listen runs the TCP tunnel acceptor until the listener closes. Listening
// again moves the tunnel: the previous listener is closed and its agents are
// dropped, so they redial the new address.
func (h *tunnelHub) listen(addr string) error {
ln, err := net.Listen("tcp", addr)
if err != nil {
return err
}
h.mu.Lock()
old := h.ln
h.ln = ln
var stale []*agentTunnel
if old != nil {
for _, at := range h.agents {
stale = append(stale, at)
}
}
h.mu.Unlock()
if old != nil {
old.Close()
}
for _, at := range stale {
at.conn.Close()
}
log.Printf("tunnel: listening on %s (direct TCP forwarding)", addr)
go func() {
for {
+21 -9
View File
@@ -11,11 +11,11 @@ import (
"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
// 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>"
// data (both): 'D' | sid(4) | port(2) | payload
@@ -23,6 +23,7 @@ type udpRelay struct {
db *DB
mu sync.Mutex
ctrl *net.UDPConn
gcOnce sync.Once
agentAddr map[string]*net.UDPAddr // agentID -> last registered addr
sessions map[uint32]*udpSession
@@ -48,7 +49,8 @@ func newUDPRelay(db *DB) *udpRelay {
}
}
// listen opens the shared 8446 UDP control socket.
// listen opens the shared UDP control socket. Listening again moves it: the
// previous socket is closed and agents register anew on the new address.
func (u *udpRelay) listen(addr string) error {
ua, err := net.ResolveUDPAddr("udp", addr)
if err != nil {
@@ -58,17 +60,26 @@ func (u *udpRelay) listen(addr string) error {
if err != nil {
return err
}
u.mu.Lock()
old := u.ctrl
u.ctrl = c
if old != nil {
u.agentAddr = map[string]*net.UDPAddr{}
}
u.mu.Unlock()
if old != nil {
old.Close()
}
log.Printf("tunnel: listening on %s/udp (direct UDP forwarding)", addr)
go u.ctrlLoop()
go u.gc()
go u.ctrlLoop(c)
u.gcOnce.Do(func() { go u.gc() })
return nil
}
func (u *udpRelay) ctrlLoop() {
func (u *udpRelay) ctrlLoop(ctrl *net.UDPConn) {
buf := make([]byte, 65535)
for {
n, src, err := u.ctrl.ReadFromUDP(buf)
n, src, err := ctrl.ReadFromUDP(buf)
if err != nil {
return
}
@@ -171,6 +182,7 @@ func (u *udpRelay) rulePortLoop(rule ForwardRule, c *net.UDPConn) {
}
u.mu.Lock()
aAddr := u.agentAddr[rule.AgentID]
ctrl := u.ctrl
// Find existing session for this (rule, user).
var sess *udpSession
for _, s := range u.sessions {
@@ -190,7 +202,7 @@ func (u *udpRelay) rulePortLoop(rule ForwardRule, c *net.UDPConn) {
sess.last = time.Now()
u.mu.Unlock()
if aAddr == nil {
if aAddr == nil || ctrl == nil {
continue // agent has not registered its UDP endpoint yet
}
// 'D' | sid(4) | clientPort(2) | payload
@@ -199,7 +211,7 @@ func (u *udpRelay) rulePortLoop(rule ForwardRule, c *net.UDPConn) {
binary.BigEndian.PutUint32(out[1:5], sess.id)
binary.BigEndian.PutUint16(out[5:7], uint16(rule.ClientPort))
copy(out[7:], buf[:n])
_, _ = u.ctrl.WriteToUDP(out, aAddr)
_, _ = ctrl.WriteToUDP(out, aAddr)
}
}
+128 -30
View File
@@ -3,8 +3,11 @@ package server
import (
"fmt"
"log"
"net"
"os"
"os/exec"
"strings"
"sync"
)
// ufwManager integrates with the host firewall (ufw) when it is installed and
@@ -15,30 +18,52 @@ import (
// (e.g. running as root). When it cannot, integration is simply inactive.
type ufwManager struct {
db *DB
tunnelPort int
// tunnelPort returns the current direct-tunnel port (0 = tunnel disabled).
tunnelPort func() int
mu sync.Mutex
lastErr string // last failed ufw command, shown in Settings
}
func newUfwManager(db *DB, tunnelPort int) *ufwManager {
func newUfwManager(db *DB, tunnelPort func() int) *ufwManager {
return &ufwManager{db: db, tunnelPort: tunnelPort}
}
// ufwRun runs ufw with a fixed C locale: its output is translated, and we
// match on the English text.
func ufwRun(args ...string) (string, error) {
cmd := exec.Command("ufw", args...)
cmd.Env = append(os.Environ(), "LC_ALL=C", "LANG=C", "LANGUAGE=C")
out, err := cmd.CombinedOutput()
return strings.TrimSpace(string(out)), err
}
// status reports whether ufw is installed, active, and runnable by this process.
func (u *ufwManager) status() (installed, active, runnable bool) {
path, err := exec.LookPath("ufw")
if err != nil {
if _, err := exec.LookPath("ufw"); err != nil {
return false, false, false
}
installed = true
out, err := exec.Command(path, "status").CombinedOutput()
out, err := ufwRun("status")
if err != nil {
// Typically a permission error (not root) -> not runnable.
return true, false, false
}
runnable = true
active = strings.Contains(string(out), "Status: active")
active = strings.Contains(out, "Status: active")
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.
func (u *ufwManager) available() bool {
if !u.db.UfwEnabled() {
@@ -48,18 +73,36 @@ func (u *ufwManager) available() bool {
return installed && active && runnable
}
// allow opens port/proto in ufw (if integration is available) and records it,
// bound to the given forward rule id (0 = shared/manual).
func (u *ufwManager) setErr(msg string) {
u.mu.Lock()
u.lastErr = msg
u.mu.Unlock()
}
// LastError returns the last failed ufw command ("" when the last one worked).
func (u *ufwManager) LastError() string {
u.mu.Lock()
defer u.mu.Unlock()
return u.lastErr
}
// allow opens port/proto in ufw and records it, bound to the given forward
// rule id (0 = shared/manual). The caller has checked available().
func (u *ufwManager) allow(port int, proto string, ruleID int64) {
if !u.available() {
return
}
if u.db.HasUfwRule(port, proto) {
return
}
spec := fmt.Sprintf("%d/%s", port, proto)
if out, err := exec.Command("ufw", "allow", spec).CombinedOutput(); err != nil {
log.Printf("ufw allow %s: %v: %s", spec, err, strings.TrimSpace(string(out)))
out, err := ufwRun("allow", spec)
if err != nil {
log.Printf("ufw allow %s: %v: %s", spec, err, out)
u.setErr(fmt.Sprintf("ufw allow %s: %v: %s", spec, err, out))
return
}
u.setErr("")
if strings.Contains(out, "Skipping") {
// The rule was there before the panel asked for it, so it is not ours
// to track (and later delete).
return
}
_ = u.db.AddUfwRule(port, proto, ruleID)
@@ -69,9 +112,11 @@ func (u *ufwManager) allow(port int, proto string, ruleID int64) {
// revokeRule deletes one tracked ufw rule from ufw and the tracking table.
func (u *ufwManager) revokeRule(r UfwRule) {
spec := fmt.Sprintf("%d/%s", r.Port, r.Proto)
if out, err := exec.Command("ufw", "delete", "allow", spec).CombinedOutput(); err != nil {
log.Printf("ufw delete allow %s: %v: %s", spec, err, strings.TrimSpace(string(out)))
if out, err := ufwRun("delete", "allow", spec); err != nil {
log.Printf("ufw delete allow %s: %v: %s", spec, err, out)
u.setErr(fmt.Sprintf("ufw delete allow %s: %v: %s", spec, err, out))
} else {
u.setErr("")
log.Printf("ufw: revoked %s", spec)
}
_ = u.db.DeleteUfwRule(r.ID)
@@ -86,8 +131,8 @@ func (u *ufwManager) revoke(id int64) {
u.revokeRule(*r)
}
// revokeForRule removes the ufw rules bound to a specific forward rule. The
// shared tunnel port (rule_id 0) is left alone, since other forwards use it.
// revokeForRule removes the ufw rules bound to a specific forward rule, and
// the shared tunnel port once no forward needs it any more.
func (u *ufwManager) revokeForRule(ruleID int64) {
rules, err := u.db.ListUfwRulesByRule(ruleID)
if err != nil {
@@ -96,24 +141,77 @@ func (u *ufwManager) revokeForRule(ruleID int64) {
for _, r := range rules {
u.revokeRule(r)
}
u.pruneShared()
}
// pruneShared revokes shared (rule_id 0) ufw rules that nothing uses: anything
// but the current tunnel port, and the tunnel port itself for a protocol that
// has no direct forward left.
func (u *ufwManager) pruneShared() {
shared, err := u.db.ListUfwRulesByRule(0)
if err != nil || len(shared) == 0 {
return
}
forwards, err := u.db.ListForwardRules()
if err != nil {
return
}
used := map[string]bool{}
for _, f := range forwards {
if !f.NoUfw {
used[f.Proto] = true
}
}
port := u.tunnelPort()
for _, r := range shared {
if r.Port != port || !used[r.Proto] {
u.revokeRule(r)
}
}
}
// 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 8446 tunnel port for
// direct tcp/udp modes (bound to 0 — shared across rules).
// 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). A rule made "without ufw"
// opens nothing, so nothing is revoked with it either.
func (u *ufwManager) allowForward(rule ForwardRule) {
switch rule.Proto {
case "udp":
u.allow(rule.HostPort, "udp", rule.ID)
if u.tunnelPort > 0 {
u.allow(u.tunnelPort, "udp", 0)
if !u.available() {
return
}
case "tcp":
u.allow(rule.HostPort, "tcp", rule.ID)
if u.tunnelPort > 0 {
u.allow(u.tunnelPort, "tcp", 0)
u.allowForwardPorts(rule)
}
func (u *ufwManager) allowForwardPorts(rule ForwardRule) {
if rule.NoUfw {
return
}
default: // http long-poll listener is TCP
u.allow(rule.HostPort, "tcp", rule.ID)
// "http" (long-poll) and "tcp" forwards both listen on TCP on the host.
hostProto, direct := "tcp", rule.Proto == "tcp"
if rule.Proto == "udp" {
hostProto, direct = "udp", true
}
// A listener bound to loopback is not reachable from outside anyway.
if ip := net.ParseIP(rule.ListenAddr); ip == nil || !ip.IsLoopback() {
u.allow(rule.HostPort, hostProto, rule.ID)
}
if port := u.tunnelPort(); direct && port > 0 {
u.allow(port, hostProto, 0)
}
}
// sync brings ufw in line with the saved forward rules: it opens what is
// missing (rules made while ufw or the integration was off, or a tunnel port
// that has since changed) and drops shared rules nothing uses.
func (u *ufwManager) sync() {
if !u.available() {
return
}
forwards, err := u.db.ListForwardRules()
if err != nil {
return
}
for _, f := range forwards {
u.allowForwardPorts(f)
}
u.pruneShared()
}
+29 -1
View File
@@ -34,10 +34,24 @@ header.topbar nav a:hover { color: var(--text); }
h1 { font-size: 22px; margin: 0 0 18px; }
h2 { font-size: 17px; margin: 0 0 14px; }
label { display: block; margin: 12px 0 4px; color: var(--muted); font-size: 13px; }
input[type=text], input[type=password] {
input[type=text], input[type=password], input[type=number] {
width: 100%; padding: 10px 12px; border-radius: 8px;
border: 1px solid var(--border); background: #0c0f13; color: var(--text); font-size: 14px;
}
/* No native spinner: it is drawn light and breaks the dark field. */
input[type=number] { -moz-appearance: textfield; appearance: textfield; }
input[type=number]::-webkit-inner-spin-button,
input[type=number]::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }
/* Sized and shaped like .btn.ghost so it lines up with the buttons next to it. */
select {
-webkit-appearance: none; appearance: none; cursor: pointer;
min-width: 180px; padding: 10px 38px 10px 14px; border-radius: 8px;
border: 1px solid var(--border); color: var(--text);
font: inherit; font-size: 14px; font-weight: 500; line-height: 1.5;
background: #0c0f13 url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%238b95a5' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E") no-repeat right 14px center;
}
select:hover, select:focus { border-color: var(--accent); outline: none; }
select:disabled, button:disabled { opacity: .5; cursor: not-allowed; }
button, .btn {
display: inline-block; cursor: pointer; border: 0; border-radius: 8px;
padding: 10px 16px; font-size: 14px; font-weight: 500;
@@ -49,10 +63,24 @@ button:hover, .btn:hover { background: var(--accent-hover); text-decoration: non
.btn.small { padding: 6px 10px; font-size: 13px; }
.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; }
/* 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; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--border); font-size: 14px; }
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.on { background: var(--ok); }
.dot.off { background: #555d6a; }
+56 -14
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Машины — LPRA</title>
<title>Машины - LPRA</title>
<link rel="stylesheet" href="/static/app.css">
</head>
<body>
@@ -22,27 +22,36 @@
<div class="card">
{{if .Agents}}
<table>
<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>
{{range .Agents}}
<tr>
<td><span class="dot {{if .Online}}on{{else}}off{{end}}"></span>{{if .Online}}online{{else}}offline{{end}}</td>
<tr data-agent="{{.ID}}">
<td class="nowrap"><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="muted">{{.Hostname}}</td>
<td class="muted">{{.OS}}/{{.Arch}}</td>
<td class="muted">{{.Seen}}</td>
<td>
<td class="muted nowrap">{{.OS}}/{{.Arch}}</td>
<td class="muted nowrap seen">{{.Seen}}</td>
<td class="actions">
<div class="row" style="gap:6px">
<a class="btn small" href="/agents/{{.ID}}/terminal">Терминал</a>
<a class="btn ghost small" href="/agents/{{.ID}}/files">Файлы</a>
<a class="btn ghost small" href="/agents/{{.ID}}/forwards">Порты</a>
<a class="btn ghost small icon" href="/agents/{{.ID}}/edit" title="Изменить">
<a class="btn small icon" href="/agents/{{.ID}}/terminal" 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="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>
</a>
<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>
</a>
<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}}">
<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>
</button>
</form>
@@ -54,9 +63,42 @@
</table>
{{else}}
<p class="muted">Пока нет подключённых машин. Добавьте первую в разделе
<a href="/settings">Настройки</a> — там одна команда для установки.</p>
<a href="/settings">Настройки</a> - там одна команда для установки.</p>
{{end}}
</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>
</html>
+47 -3
View File
@@ -3,14 +3,18 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Изменить машину — LPRA</title>
<link rel="stylesheet" href="/static/app.css">
<title>Изменить машину - LPRA</title>
<link rel="stylesheet" href="/static/app.css?v={{.ServerVersion}}-2">
</head>
<body>
<header class="topbar">
<div class="brand">LPRA · Изменить машину</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}}/status">Статус</a>
</nav>
</header>
<div class="wrap">
@@ -28,6 +32,46 @@
</p>
</div>
<div class="card">
<h2>Пользователь</h2>
{{if .UserOK}}<div class="err" style="background:#1d3a24;border-color:#2b6b3a;color:#b3ffcb">{{.UserOK}}</div>{{end}}
{{if .UserErr}}<div class="err">{{.UserErr}}</div>{{end}}
<p class="muted">
Клиент запущен от: <code>{{if .Users}}{{.Users.Current}}{{else if .Agent.RunUser}}{{.Agent.RunUser}}{{else}}неизвестно{{end}}</code>
· Терминал открывается от: <code>{{.Agent.TerminalUser}}</code>
</p>
{{if .Users}}
<form method="post" action="/agents/{{.Agent.ID}}/user" class="row">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<select name="user" {{if not .Users.CanSwitch}}disabled{{end}}>
{{range .Users.Users}}<option value="{{.}}" {{if eq . $.Agent.TerminalUser}}selected{{end}}>{{.}}</option>{{end}}
</select>
<button type="submit" {{if not .Users.CanSwitch}}disabled{{end}}>Сохранить</button>
{{if .Users.CanSwitch}}<button type="button" class="btn ghost" onclick="document.getElementById('newuser').hidden = !document.getElementById('newuser').hidden">Создать нового</button>{{end}}
</form>
{{if .Users.CanSwitch}}
<form id="newuser" method="post" action="/agents/{{.Agent.ID}}/users/add" style="max-width:360px; margin-top:16px" hidden>
<input type="hidden" name="csrf" value="{{.CSRF}}">
<label for="nu-name">Имя нового пользователя</label>
<input type="text" id="nu-name" name="name" pattern="[a-z_][a-z0-9_\-]{0,31}" autocomplete="off" required>
<label for="nu-pass">Пароль</label>
<input type="password" id="nu-pass" name="password" autocomplete="new-password">
<label style="color:var(--text); font-size:14px">
<input type="checkbox" name="sudo" value="1"> Добавить в sudo
</label>
<p class="muted" style="font-size:13px">Без пароля вход по паролю (и sudo) для него работать не будет - только терминал из панели.</p>
<button type="submit">Создать пользователя</button>
</form>
{{else}}
<p class="muted" style="font-size:13px; margin-top:12px">Клиент запущен не от root, поэтому сменить или создать пользователя нельзя.</p>
{{end}}
{{else if .UsersErr}}
<p class="muted" style="font-size:13px">Список пользователей недоступен: {{.UsersErr}}.</p>
{{else}}
<p class="muted" style="font-size:13px">Машина офлайн - сменить пользователя можно, когда она на связи.</p>
{{end}}
</div>
<div class="card">
<h2>Версия клиента</h2>
{{if .Updated}}<div class="err" style="background:#1d3a24;border-color:#2b6b3a;color:#b3ffcb">Команда обновления отправлена. Клиент скачает свежий бинарник и перезапустится.</div>{{end}}
@@ -50,7 +94,7 @@
<div class="card">
<h2>Удаление</h2>
<p class="muted">Удаляет машину из панели. Сервис на самой машине продолжит работать,
но перестанет быть виден; чтобы подключиться снова — переустановите агента.</p>
но перестанет быть виден; чтобы подключиться снова - переустановите агента.</p>
<form method="post" action="/agents/{{.Agent.ID}}/delete"
onsubmit="return confirm('Удалить машину {{.Agent.Name}}?')">
<input type="hidden" name="csrf" value="{{.CSRF}}">
+8 -6
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<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">
<style>
.fm-bar { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-bottom:12px; }
@@ -44,11 +44,13 @@
</head>
<body>
<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>
<a href="/">← Машины</a>
<a href="/agents/{{.Agent.ID}}/terminal">Терминал</a>
<a href="/agents/{{.Agent.ID}}/forwards">Порты</a>
<a href="/agents/{{.Agent.ID}}/status">Статус</a>
<a href="/agents/{{.Agent.ID}}/edit">Изменить</a>
</nav>
</header>
<div class="wrap" style="max-width:1100px">
@@ -78,7 +80,7 @@
<tbody id="list"></tbody>
</table>
</div>
<p class="muted" id="hint" style="font-size:13px">Перетащите файлы сюда для загрузки. Клик по файлу — просмотр (правка, если &lt; 2 КБ).</p>
<p class="muted" id="hint" style="font-size:13px">Перетащите файлы сюда для загрузки. Клик по файлу - просмотр и правка.</p>
</div>
<dialog id="viewer">
@@ -107,7 +109,7 @@
}).then(r => r.json()).catch(e => ({ok:false, error:'ошибка сети'})).finally(() => setBusy(false));
};
const EDIT_LIMIT = 5 * 1024 * 1024;
const EDIT_LIMIT = {{.EditLimit}};
// Monochrome inline icons (inherit button color via currentColor).
const SVG = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">';
@@ -190,10 +192,10 @@
msg.textContent = 'Размер: '+fmtSize(d.size);
} else {
ta.value = new TextDecoder().decode(b64ToBytes(d.content));
const editable = d.size < EDIT_LIMIT && !d.truncated;
const editable = d.size <= EDIT_LIMIT && !d.truncated;
ta.readOnly = !editable;
save.style.display = editable ? '' : 'none';
msg.textContent = 'Размер: '+fmtSize(d.size) + (d.truncated?' · показано первые 5 МБ':'') + (editable?' · редактируемо':' · только чтение (≥ 5 МБ)');
msg.textContent = 'Размер: '+fmtSize(d.size) + (d.truncated?' · показано первые '+fmtSize(EDIT_LIMIT):'') + (editable?' · редактируемо':' · только чтение (больше '+fmtSize(EDIT_LIMIT)+')');
save.onclick = async () => {
const bytes = new TextEncoder().encode(ta.value);
const w = await api('write', {path: join(cwd, e.name), data: bytesToB64(bytes)});
+21 -13
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<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">
<style>
.frow { display:flex; align-items:flex-end; gap:14px; flex-wrap:wrap; }
@@ -12,6 +12,7 @@
.frow select, .frow input, .frow button {
height:40px; box-sizing:border-box; font-size:14px; line-height:1; margin:0;
}
label.check { display:inline-flex; align-items:center; gap:8px; margin:14px 0 0; color:var(--text); font-size:14px; cursor:pointer; }
.frow select {
padding:0 32px 0 12px; border-radius:8px; border:1px solid var(--border);
background:#0c0f13; color:var(--text);
@@ -20,17 +21,19 @@
</head>
<body>
<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>
<a href="/">← Машины</a>
<a href="/agents/{{.Agent.ID}}/terminal">Терминал</a>
<a href="/agents/{{.Agent.ID}}/files">Файлы</a>
<a href="/agents/{{.Agent.ID}}/status">Статус</a>
<a href="/agents/{{.Agent.ID}}/edit">Изменить</a>
</nav>
</header>
<div class="wrap">
<h1>Проброс портов</h1>
<p class="muted">Порт сервиса на машине-клиенте становится доступен на порту хоста.
Клиент за NAT — трафик идёт исходящим подключением агента.</p>
Клиент за NAT - трафик идёт исходящим подключением агента.</p>
{{if .Error}}<div class="err">{{.Error}}</div>{{end}}
<div class="card">
@@ -44,7 +47,7 @@
<td><code>{{.Proto}}</code></td>
<td class="muted">:{{.HostPort}}</td>
<td class="muted">127.0.0.1:{{.ClientPort}}</td>
<td class="muted">{{.ListenAddr}}</td>
<td class="muted">{{.ListenAddr}}{{if and .NoUfw $.UfwEnabled}} · без ufw{{end}}</td>
<td style="text-align:right">
<form method="post" action="/forwards/{{.ID}}/delete" style="display:inline"
onsubmit="return confirm('Удалить правило?')">
@@ -70,8 +73,8 @@
<label>Тип</label>
<select name="proto" id="proto">
<option value="http">http (long-poll, работает везде)</option>
<option value="tcp">tcp (прямой, низкая задержка, порт 8446)</option>
<option value="udp">udp (прямой, порт 8446)</option>
<option value="tcp">tcp (прямой, низкая задержка, порт {{.TunnelPort}})</option>
<option value="udp">udp (прямой, порт {{.TunnelPort}})</option>
</select>
</div>
<div class="fld">
@@ -88,6 +91,11 @@
</div>
<button type="submit">Создать</button>
</div>
{{if .UfwEnabled}}
<label class="check" title="Порт не будет открыт в ufw, и при удалении правила в ufw ничего не изменится">
<input type="checkbox" name="no_ufw" value="1"> Без ufw правила
</label>
{{end}}
</form>
<p class="muted" id="note" style="font-size:13px; margin-top:12px"></p>
</div>
@@ -95,18 +103,18 @@
<div class="card">
<h2>Режимы</h2>
<ul class="muted" style="font-size:14px; line-height:1.7">
<li><b>http</b> — туннель поверх HTTP long-poll (через nginx :8445). Работает везде, где доступна панель, но задержка выше.</li>
<li><b>tcp</b> — прямой TCP-мультиплексор через порт <b>8446</b> хоста. Минимальная задержка, keep-alive, TCP_NODELAY. Нужно открыть 8446/tcp на хосте.</li>
<li><b>udp</b> — прямой UDP через порт <b>8446</b> хоста. Нужно открыть 8446/udp.</li>
<li><b>http</b> - туннель поверх HTTP long-poll (через адрес панели, порт {{.PanelPort}}). Работает везде, где доступна панель, но задержка выше.</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>
</ul>
<p class="muted" style="font-size:13px">Для tcp/udp агент держит исходящее соединение к <code>host:8446</code>; несколько клиентов и портов мультиплексируются на нём.</p>
<p class="muted" style="font-size:13px">Для tcp/udp агент держит исходящее соединение к <code>host:{{.TunnelPort}}</code>; несколько клиентов и портов мультиплексируются на нём.</p>
</div>
</div>
<script>
var notes = {
http: 'Трафик идёт через :8445 (long-poll). Порт 8446 не требуется.',
tcp: 'Требуется открытый порт 8446/tcp на хосте. Самая низкая задержка.',
udp: 'Требуется открытый порт 8446/udp на хосте.'
http: 'Трафик идёт через порт панели {{.PanelPort}} (long-poll). Порт {{.TunnelPort}} не требуется.',
tcp: 'Требуется открытый порт {{.TunnelPort}}/tcp на хосте. Самая низкая задержка.',
udp: 'Требуется открытый порт {{.TunnelPort}}/udp на хосте.'
};
var sel = document.getElementById('proto'), note = document.getElementById('note');
function upd(){ note.textContent = notes[sel.value] || ''; }
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Вход — LPRA</title>
<title>Вход - LPRA</title>
<link rel="stylesheet" href="/static/app.css">
</head>
<body>
+25 -3
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Настройки — LPRA</title>
<title>Настройки - LPRA</title>
<link rel="stylesheet" href="/static/app.css">
</head>
<body>
@@ -19,6 +19,7 @@
</header>
<div class="wrap">
<h1>Настройки</h1>
{{if .Error}}<div class="err">{{.Error}}</div>{{end}}
<div class="card">
<h2>Установка на новую машину</h2>
@@ -49,6 +50,26 @@
</form>
</div>
<div class="card">
<h2>Прямой туннель (tcp/udp проброс)</h2>
{{if not .TunnelDefault}}
<p class="muted">Прямой туннель отключён при запуске сервера (пустой <code>-tunnel</code> / <code>LPRA_TUNNEL_ADDR</code>).</p>
{{else}}
<p class="muted">Адрес, к которому агенты подключаются для проброса в режимах tcp/udp. Агенты забирают его
с панели сами - при подключении и при каждом опросе, так что смена применяется без переустановки.
Хост можно оставить пустым (тогда берётся хост из адреса панели) или указать IP/другое имя.</p>
<form method="post" action="/settings/tunnel" class="row">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<input type="text" name="tunnel_host" value="{{.TunnelHost}}" placeholder="хост (как у панели)" style="max-width:260px">
<input type="number" name="tunnel_port" min="1" max="65535" value="{{if .TunnelCustom}}{{.TunnelPort}}{{end}}" placeholder="{{.TunnelDefault}}" style="max-width:120px">
<button type="submit">Сохранить</button>
</form>
<p class="muted" style="font-size:13px">Сейчас сервер слушает порт <b>{{.TunnelPort}}</b> (tcp и udp). При смене порта сервер
сразу начинает слушать новый, агенты переподключаются в течение ~30 секунд, правило ufw переносится.
Агенты старше 0.0.3 и агенты с заданными <code>TUNNEL_ADDR</code>/<code>TUNNEL_PORT</code> адрес с панели не берут.</p>
{{end}}
</div>
<div class="card">
<h2>Enroll-секрет</h2>
<p class="muted">Общий секрет для регистрации новых агентов.</p>
@@ -94,7 +115,7 @@
<div class="card">
<h2>Firewall (ufw)</h2>
{{if not .UfwInstalled}}
<p class="muted">ufw не установлен на хосте — интеграция недоступна.</p>
<p class="muted">ufw не установлен на хосте - интеграция недоступна.</p>
{{else if not .UfwRunnable}}
<p class="muted">ufw установлен, но сервер не может им управлять (нужны права root).
Запустите сервер под root или настройте sudo для ufw.</p>
@@ -103,7 +124,8 @@
Статус 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}}
</p>
<p class="muted" style="font-size:13px">При создании правила проброса порт открывается в ufw автоматически (для tcp/udp — также порт 8446).</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}}
<form method="post" action="/settings/ufw/toggle" style="display:inline">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<input type="hidden" name="enabled" value="{{if .UfwEnabled}}0{{else}}1{{end}}">
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Настройка — LPRA</title>
<title>Настройка - LPRA</title>
<link rel="stylesheet" href="/static/app.css">
</head>
<body>
+157
View File
@@ -0,0 +1,157 @@
<!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; }
</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-проброс.
Замер занимает около 7 секунд и на это время нагружает канал.</p>
<div class="row">
<button id="speed-btn" type="button">Замерить скорость</button>
<span class="muted" id="speed-note"></span>
</div>
<div class="speed" id="speed" hidden>
<div><b id="sp-ping"></b><span>задержка</span></div>
<div><b id="sp-down"></b><span>хост → клиент</span></div>
<div><b id="sp-up"></b><span>клиент → хост</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 rate(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 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');
btn.onclick = function () {
btn.disabled = true;
note.textContent = 'Идёт замер…';
fetch(base + '/speed', {method: 'POST', credentials: 'same-origin'})
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
.then(function (res) {
if (!res.ok) { note.textContent = res.error || 'ошибка'; return; }
var d = res.data;
$('sp-ping').textContent = d.ping_ms.toFixed(d.ping_ms < 10 ? 1 : 0) + ' мс';
$('sp-down').textContent = rate(d.down_bps);
$('sp-up').textContent = rate(d.up_bps);
$('speed').hidden = false;
note.textContent = '';
})
.catch(function (e) { note.textContent = 'Не удалось: ' + e.message; })
.then(function () { btn.disabled = false; });
};
})();
</script>
</body>
</html>
+5 -3
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<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/xterm.css">
<style>
@@ -13,11 +13,13 @@
</head>
<body>
<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.TerminalUser}}@{{.Agent.Hostname}})</span></div>
<nav>
<a href="/">← Машины</a>
<a href="/agents/{{.Agent.ID}}/files">Файлы</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>
</nav>
</header>
@@ -78,7 +80,7 @@
setStatus('ошибка открытия (' + r.status + ')', 'st-err');
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 (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;
}
const j = await r.json();
+1 -1
View File
@@ -1,5 +1,5 @@
// 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
// carries many concurrent forwarded connections (streams) for one agent and
// has far lower latency than long-polling.
+14
View File
@@ -0,0 +1,14 @@
// Package lpra holds what the server and the agent share at build time.
package lpra
import (
_ "embed"
"strings"
)
//go:embed VERSION.txt
var versionFile string
// Version is the release version of every binary in this module. VERSION.txt
// is the only place it is defined.
var Version = strings.TrimSpace(versionFile)