fix: small fixes

This commit is contained in:
redtea committed 2026-10-13 19:00:50 +03:00
1 parent 86cb0226ab
commit 171e14176a
28 files changed
+95 -91

No files matched your search

+14 -14
View File
@@ -1,4 +1,4 @@
# LPRA — LongPolling Remote Access # LPRA - LongPolling Remote Access
Лёгкий self-hosted сервис для доступа к Linux-машинам **без белого IP** (за NAT). Лёгкий self-hosted сервис для доступа к Linux-машинам **без белого IP** (за NAT).
Агент на клиенте сам исходяще подключается к серверу по HTTP long-polling; админ Агент на клиенте сам исходяще подключается к серверу по HTTP long-polling; админ
@@ -11,9 +11,9 @@
``` ```
## Компоненты ## Компоненты
- **Сервер** (`cmd/server`) — админ-панель + точка встречи для агентов. За nginx (TLS). - **Сервер** (`cmd/server`) - админ-панель + точка встречи для агентов. За nginx (TLS).
- **Агент** (`cmd/agent`) — Linux-бинарь, systemd-сервис от root, PTY по запросу. - **Агент** (`cmd/agent`) - Linux-бинарь, systemd-сервис от root, PTY по запросу.
- **install.sh** — интерактивный установщик, отдаётся сервером как статика. - **install.sh** - интерактивный установщик, отдаётся сервером как статика.
## Сборка ## Сборка
```sh ```sh
@@ -28,7 +28,7 @@
Поставьте перед ним nginx по `deploy/nginx.conf.example` (TLS, long-poll/WS). Поставьте перед ним nginx по `deploy/nginx.conf.example` (TLS, long-poll/WS).
Локально без TLS можно добавить `-insecure-cookies`. Локально без TLS можно добавить `-insecure-cookies`.
Первый запуск: откройте `/setup` — мастер создаст администратора. Дальше `/settings`: Первый запуск: откройте `/setup` - мастер создаст администратора. Дальше `/settings`:
адрес хоста (по умолчанию `https://localhost:8445`), enroll-секрет и готовая адрес хоста (по умолчанию `https://localhost:8445`), enroll-секрет и готовая
команда установки. команда установки.
@@ -37,36 +37,36 @@
```sh ```sh
curl -fsSL https://localhost:8445/install.sh | sudo HOST=https://localhost:8445 ENROLL_SECRET=<секрет> sh curl -fsSL https://localhost:8445/install.sh | sudo HOST=https://localhost:8445 ENROLL_SECRET=<секрет> sh
``` ```
Или интерактивно (спросит секрет, а если агент уже стоит — предложит удалить): Или интерактивно (спросит секрет, а если агент уже стоит - предложит удалить):
```sh ```sh
curl -fsSL https://localhost:8445/install.sh | sudo sh curl -fsSL https://localhost:8445/install.sh | sudo sh
``` ```
## Проброс портов и прямой туннель ## Проброс портов и прямой туннель
Правила проброса создаются на странице машины. Режим `http` идёт через адрес панели (long-poll), Правила проброса создаются на странице машины. Режим `http` идёт через адрес панели (long-poll),
режимы `tcp`/`udp` — через прямой туннель: агент держит исходящее соединение к порту туннеля хоста режимы `tcp`/`udp` - через прямой туннель: агент держит исходящее соединение к порту туннеля хоста
(по умолчанию `8446`, флаг `-tunnel` / `LPRA_TUNNEL_ADDR`). (флаг `-tunnel` / `LPRA_TUNNEL_ADDR`).
Порт туннеля и хост, к которому для него подключаются агенты (например, IP вместо домена панели), Порт туннеля и хост, к которому для него подключаются агенты (например, IP вместо домена панели),
меняются в `/settings` → «Прямой туннель». Агенты забирают адрес с панели сами — при подключении и меняются в `/settings` → «Прямой туннель». Агенты забирают адрес с панели сами - при подключении и
при каждом опросе, переустановка не нужна. Локальные `TUNNEL_ADDR` / `TUNNEL_PORT` в окружении агента при каждом опросе, переустановка не нужна. Локальные `TUNNEL_ADDR` / `TUNNEL_PORT` в окружении агента
имеют приоритет над настройкой панели (`TUNNEL_ADDR=off` отключает туннель). имеют приоритет над настройкой панели (`TUNNEL_ADDR=off` отключает туннель).
Если на хосте активен ufw и сервер запущен от root, панель сама открывает порт хоста правила Если на хосте активен ufw и сервер запущен от root, панель сама открывает порт хоста правила
(tcp для `http`/`tcp`, udp для `udp`) и порт туннеля для прямых режимов, а при удалении правила — закрывает. (tcp для `http`/`tcp`, udp для `udp`) и порт туннеля для прямых режимов, а при удалении правила - закрывает.
При `ProtectSystem=strict` в systemd-юните ufw нужны права на запись: `ReadWritePaths=/etc/ufw /run`. При `ProtectSystem=strict` в systemd-юните ufw нужны права на запись: `ReadWritePaths=/etc/ufw /run`.
## Версия ## Версия
Версия задаётся в одном месте — `VERSION.txt`; файл вшивается в сервер и агент при сборке. Версия задаётся в одном месте - `VERSION.txt`; файл вшивается в сервер и агент при сборке.
## Пользователь терминала ## Пользователь терминала
По умолчанию терминал открывается от пользователя, под которым запущен агент (на Linux — root). По умолчанию терминал открывается от пользователя, под которым запущен агент (на Linux - root).
В окне «Изменить машину» можно выбрать другого пользователя машины или создать нового В окне «Изменить машину» можно выбрать другого пользователя машины или создать нового
(имя, пароль, опционально — добавить в sudo). Смена и создание работают, только если агент запущен от root. (имя, пароль, опционально - добавить в sudo). Смена и создание работают, только если агент запущен от root.
Файловый менеджер по-прежнему работает от пользователя агента. Файловый менеджер по-прежнему работает от пользователя агента.
## Безопасность ## Безопасность
- Пароли админов — bcrypt; сессии в HttpOnly/Secure/SameSite куках; CSRF на формах; rate-limit логина. - Пароли админов - bcrypt; сессии в HttpOnly/Secure/SameSite куках; CSRF на формах; rate-limit логина.
- Агенты: общий enroll-секрет → индивидуальный токен (хранится хешем, сверка constant-time). - Агенты: общий enroll-секрет → индивидуальный токен (хранится хешем, сверка constant-time).
- Открытие терминала пишется в таблицу `audit` (терминал даёт root-доступ), как и смена/создание пользователя. - Открытие терминала пишется в таблицу `audit` (терминал даёт root-доступ), как и смена/создание пользователя.
+4 -2
View File
@@ -10,9 +10,11 @@ import (
"net/url" "net/url"
"os" "os"
"os/signal" "os/signal"
"strconv"
"syscall" "syscall"
"github.com/x-grk/longpolling-remote-access/internal/agent" "github.com/x-grk/longpolling-remote-access/internal/agent"
"github.com/x-grk/longpolling-remote-access/internal/tunnel"
) )
func main() { func main() {
@@ -48,7 +50,7 @@ func envOr(key, def string) string {
return def return def
} }
// tunnelAddr derives the direct-tunnel address (host:8446) from the server URL. // tunnelAddr derives the direct-tunnel address (host:port) from the server URL.
// It is only the starting point: the agent follows the host and port set in // It is only the starting point: the agent follows the host and port set in
// the panel settings, unless TUNNEL_ADDR (the whole address) or TUNNEL_PORT // the panel settings, unless TUNNEL_ADDR (the whole address) or TUNNEL_PORT
// (just the port) pins it here. Set TUNNEL_ADDR=off to disable the tunnel. // (just the port) pins it here. Set TUNNEL_ADDR=off to disable the tunnel.
@@ -63,5 +65,5 @@ func tunnelAddr(hostURL string) string {
if err != nil || u.Hostname() == "" { if err != nil || u.Hostname() == "" {
return "" return ""
} }
return net.JoinHostPort(u.Hostname(), envOr("TUNNEL_PORT", "8446")) return net.JoinHostPort(u.Hostname(), envOr("TUNNEL_PORT", strconv.Itoa(tunnel.DefaultPort)))
} }
+3 -1
View File
@@ -10,16 +10,18 @@ import (
"log" "log"
"net/http" "net/http"
"os" "os"
"strconv"
"time" "time"
"github.com/x-grk/longpolling-remote-access/internal/server" "github.com/x-grk/longpolling-remote-access/internal/server"
"github.com/x-grk/longpolling-remote-access/internal/tunnel"
) )
func main() { func main() {
addr := flag.String("addr", envOr("LPRA_ADDR", "127.0.0.1:8091"), "listen address (behind nginx)") addr := flag.String("addr", envOr("LPRA_ADDR", "127.0.0.1:8091"), "listen address (behind nginx)")
dbPath := flag.String("db", envOr("LPRA_DB", "lpra.db"), "path to SQLite database") dbPath := flag.String("db", envOr("LPRA_DB", "lpra.db"), "path to SQLite database")
distDir := flag.String("dist", envOr("LPRA_DIST", "dist"), "directory with agent binaries served at /download/") distDir := flag.String("dist", envOr("LPRA_DIST", "dist"), "directory with agent binaries served at /download/")
tunnelAddr := flag.String("tunnel", envOr("LPRA_TUNNEL_ADDR", ":8446"), "listen address for the direct TCP+UDP forward tunnel (empty disables)") tunnelAddr := flag.String("tunnel", envOr("LPRA_TUNNEL_ADDR", ":"+strconv.Itoa(tunnel.DefaultPort)), "listen address for the direct TCP+UDP forward tunnel (empty disables)")
insecureCookies := flag.Bool("insecure-cookies", envOr("LPRA_INSECURE_COOKIES", "") == "1", insecureCookies := flag.Bool("insecure-cookies", envOr("LPRA_INSECURE_COOKIES", "") == "1",
"do not mark cookies Secure (plain-HTTP local testing only)") "do not mark cookies Secure (plain-HTTP local testing only)")
flag.Parse() flag.Parse()
+5 -5
View File
@@ -10,15 +10,15 @@
# sudo cp deploy/lpra-server.service /etc/systemd/system/ # sudo cp deploy/lpra-server.service /etc/systemd/system/
# sudo systemctl daemon-reload && sudo systemctl enable --now lpra-server # sudo systemctl daemon-reload && sudo systemctl enable --now lpra-server
# #
# Direct port forwarding (tcp/udp modes) uses port 8446 on the host — open it in # Direct port forwarding (tcp/udp modes) uses the tunnel port on the host, which
# the firewall: ufw allow 8446/tcp ; ufw allow 8446/udp # has to be open in the firewall for tcp and udp. (The "http" forward mode and
# (The "http" forward mode and terminal/files go through nginx :8445 and need # terminal/files go through nginx :8445 and need nothing extra.) Set the tunnel
# nothing extra.) Override with LPRA_TUNNEL_ADDR, or set it empty to disable. # address with LPRA_TUNNEL_ADDR, or set it empty to disable.
# The port, and the host agents dial for it, can also be changed in the panel # The port, and the host agents dial for it, can also be changed in the panel
# settings; agents pick the new address up from the panel by themselves. # settings; agents pick the new address up from the panel by themselves.
# #
# ufw integration (the panel opening forwarded ports itself) needs root and a # ufw integration (the panel opening forwarded ports itself) needs root and a
# writable /etc/ufw and /run — see ReadWritePaths below. # writable /etc/ufw and /run - see ReadWritePaths below.
[Unit] [Unit]
Description=LPRA remote access host (admin panel + agent rendezvous) Description=LPRA remote access host (admin panel + agent rendezvous)
+1 -1
View File
@@ -33,7 +33,7 @@ type Config struct {
EnrollSecret string EnrollSecret string
Name string // optional friendly name Name string // optional friendly name
StatePath string // where the enrolled id+token are persisted StatePath string // where the enrolled id+token are persisted
TunnelAddr string // host:port for the direct forward tunnel (e.g. REDACTED:8446); empty disables TunnelAddr string // host:port for the direct forward tunnel (e.g. panel.example:port); empty disables
// TunnelPinned keeps TunnelAddr as configured locally (TUNNEL_ADDR / // TunnelPinned keeps TunnelAddr as configured locally (TUNNEL_ADDR /
// TUNNEL_PORT) instead of following the address set in the panel. // TUNNEL_PORT) instead of following the address set in the panel.
TunnelPinned bool TunnelPinned bool
+1 -1
View File
@@ -15,7 +15,7 @@ type PTY struct {
} }
// StartPTY launches a login shell attached to a new PTY. With user empty (or // StartPTY launches a login shell attached to a new PTY. With user empty (or
// equal to the agent's own user) it is the agent's shell — root by default, // equal to the agent's own user) it is the agent's shell - root by default,
// since the agent runs as a root systemd service. Any other user is entered via // since the agent runs as a root systemd service. Any other user is entered via
// `su -l`, which only a root agent can do without a password. // `su -l`, which only a root agent can do without a password.
func StartPTY(user string) (*PTY, error) { func StartPTY(user string) (*PTY, error) {
+1 -1
View File
@@ -17,7 +17,7 @@ import (
// doUpdate downloads the latest agent binary for this platform, atomically // doUpdate downloads the latest agent binary for this platform, atomically
// replaces the running executable, and schedules a service restart. It never // replaces the running executable, and schedules a service restart. It never
// uninstalls — only updates. // uninstalls - only updates.
func (a *Agent) doUpdate() proto.TaskResult { func (a *Agent) doUpdate() proto.TaskResult {
exe, err := os.Executable() exe, err := os.Executable()
if err != nil { if err != nil {
+1 -1
View File
@@ -400,7 +400,7 @@ type UfwRule struct {
ID int64 ID int64
Port int Port int
Proto string Proto string
RuleID int64 // bound forward rule id, or 0 for shared/manual (e.g. tunnel 8446) RuleID int64 // bound forward rule id, or 0 for shared/manual (e.g. the tunnel port)
} }
func (db *DB) AddUfwRule(port int, proto string, ruleID int64) error { func (db *DB) AddUfwRule(port int, proto string, ruleID int64) error {
+4 -4
View File
@@ -14,8 +14,8 @@ import (
// supports three transports: // supports three transports:
// - "http": tunneled over the HTTP long-poll channel (works anywhere nginx // - "http": tunneled over the HTTP long-poll channel (works anywhere nginx
// reaches, highest latency); // reaches, highest latency);
// - "tcp": direct TCP multiplexed over the 8446 tunnel (low latency); // - "tcp": direct TCP multiplexed over the tunnel port (low latency);
// - "udp": direct UDP over the 8446 channel. // - "udp": direct UDP over the tunnel port.
type forwardManager struct { type forwardManager struct {
hub *Hub hub *Hub
tun *tunnelHub tun *tunnelHub
@@ -92,11 +92,11 @@ func (m *forwardManager) acceptTCP(rule ForwardRule, ln net.Listener) {
} }
} }
// bridgeDirect wires a connection to the agent's low-latency 8446 tunnel. // bridgeDirect wires a connection to the agent's low-latency direct tunnel.
func (m *forwardManager) bridgeDirect(rule ForwardRule, conn net.Conn) { func (m *forwardManager) bridgeDirect(rule ForwardRule, conn net.Conn) {
at := m.tun.get(rule.AgentID) at := m.tun.get(rule.AgentID)
if at == nil { if at == nil {
// No direct tunnel (agent offline or 8446 unreachable). // No direct tunnel (agent offline or the tunnel port unreachable).
conn.Close() conn.Close()
return return
} }
+7 -7
View File
@@ -16,7 +16,7 @@ func writeJSON(w http.ResponseWriter, v any) {
_ = json.NewEncoder(w).Encode(v) _ = json.NewEncoder(w).Encode(v)
} }
// POST /api/agent/enroll — exchange the shared enroll secret for a per-agent id+token. // POST /api/agent/enroll - exchange the shared enroll secret for a per-agent id+token.
func (s *Server) handleEnroll(w http.ResponseWriter, r *http.Request) { func (s *Server) handleEnroll(w http.ResponseWriter, r *http.Request) {
secret := r.Header.Get("X-Enroll-Secret") secret := r.Header.Get("X-Enroll-Secret")
if !constantTimeEqual(secret, s.db.EnrollSecret()) { if !constantTimeEqual(secret, s.db.EnrollSecret()) {
@@ -36,7 +36,7 @@ func (s *Server) handleEnroll(w http.ResponseWriter, r *http.Request) {
writeJSON(w, proto.EnrollResponse{AgentID: id, Token: token}) writeJSON(w, proto.EnrollResponse{AgentID: id, Token: token})
} }
// GET /api/agent/poll — control channel. Held until a job is queued or timeout. // GET /api/agent/poll - control channel. Held until a job is queued or timeout.
func (s *Server) handlePoll(w http.ResponseWriter, r *http.Request) { func (s *Server) handlePoll(w http.ResponseWriter, r *http.Request) {
a := s.authenticateAgent(r) a := s.authenticateAgent(r)
if a == nil { if a == nil {
@@ -68,7 +68,7 @@ func (s *Server) setTunnelHeader(w http.ResponseWriter) {
} }
} }
// GET /api/agent/config — settings an agent picks up when it connects, without // GET /api/agent/config - settings an agent picks up when it connects, without
// waiting out a poll hold. // waiting out a poll hold.
func (s *Server) handleAgentConfig(w http.ResponseWriter, r *http.Request) { func (s *Server) handleAgentConfig(w http.ResponseWriter, r *http.Request) {
if s.authenticateAgent(r) == nil { if s.authenticateAgent(r) == nil {
@@ -79,7 +79,7 @@ func (s *Server) handleAgentConfig(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
// GET /api/agent/task/{id} — the agent fetches the task to run. // GET /api/agent/task/{id} - the agent fetches the task to run.
func (s *Server) handleTaskFetch(w http.ResponseWriter, r *http.Request) { func (s *Server) handleTaskFetch(w http.ResponseWriter, r *http.Request) {
if s.authenticateAgent(r) == nil { if s.authenticateAgent(r) == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized) http.Error(w, "unauthorized", http.StatusUnauthorized)
@@ -93,7 +93,7 @@ func (s *Server) handleTaskFetch(w http.ResponseWriter, r *http.Request) {
writeJSON(w, env) writeJSON(w, env)
} }
// POST /api/agent/task/{id}/result — the agent returns the task result. // POST /api/agent/task/{id}/result - the agent returns the task result.
func (s *Server) handleTaskResult(w http.ResponseWriter, r *http.Request) { func (s *Server) handleTaskResult(w http.ResponseWriter, r *http.Request) {
if s.authenticateAgent(r) == nil { if s.authenticateAgent(r) == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized) http.Error(w, "unauthorized", http.StatusUnauthorized)
@@ -108,7 +108,7 @@ func (s *Server) handleTaskResult(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
// GET /api/agent/term/{sid}/in — downstream (panel -> agent) long-poll. // GET /api/agent/term/{sid}/in - downstream (panel -> agent) long-poll.
func (s *Server) handleTermIn(w http.ResponseWriter, r *http.Request) { func (s *Server) handleTermIn(w http.ResponseWriter, r *http.Request) {
if s.authenticateAgent(r) == nil { if s.authenticateAgent(r) == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized) http.Error(w, "unauthorized", http.StatusUnauthorized)
@@ -127,7 +127,7 @@ func (s *Server) handleTermIn(w http.ResponseWriter, r *http.Request) {
writeJSON(w, proto.InBatch{Frames: frames}) writeJSON(w, proto.InBatch{Frames: frames})
} }
// POST /api/agent/term/{sid}/out — upstream (agent PTY -> panel). // POST /api/agent/term/{sid}/out - upstream (agent PTY -> panel).
func (s *Server) handleTermOut(w http.ResponseWriter, r *http.Request) { func (s *Server) handleTermOut(w http.ResponseWriter, r *http.Request) {
if s.authenticateAgent(r) == nil { if s.authenticateAgent(r) == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized) http.Error(w, "unauthorized", http.StatusUnauthorized)
+5 -5
View File
@@ -137,7 +137,7 @@ func seenText(online bool, lastSeen time.Time) string {
return humanSince(lastSeen) return humanSince(lastSeen)
} }
// GET /api/panel/agents/presence — who is online, for the dashboard to keep // GET /api/panel/agents/presence - who is online, for the dashboard to keep
// its status column live without reloading. // its status column live without reloading.
func (s *Server) handlePresence(w http.ResponseWriter, r *http.Request) { func (s *Server) handlePresence(w http.ResponseWriter, r *http.Request) {
agents, err := s.db.ListAgents() agents, err := s.db.ListAgents()
@@ -227,7 +227,7 @@ func (s *Server) agentUsers(agentID string) (*proto.SysUsersResult, error) {
case res := <-ch: case res := <-ch:
if !res.OK { if !res.OK {
// Agents older than 0.0.2 don't know this task. // Agents older than 0.0.2 don't know this task.
return nil, errors.New("клиент не поддерживает выбор пользователя — обновите его") return nil, errors.New("клиент не поддерживает выбор пользователя - обновите его")
} }
var out proto.SysUsersResult var out proto.SysUsersResult
if err := json.Unmarshal(res.Data, &out); err != nil { if err := json.Unmarshal(res.Data, &out); err != nil {
@@ -258,7 +258,7 @@ func (s *Server) handleAgentSetUser(w http.ResponseWriter, r *http.Request) {
} }
name := strings.TrimSpace(r.FormValue("user")) name := strings.TrimSpace(r.FormValue("user"))
if !s.online(a) { if !s.online(a) {
editUserRedirect(w, r, id, "uerr", "Машина офлайн — сменить пользователя нельзя.") editUserRedirect(w, r, id, "uerr", "Машина офлайн - сменить пользователя нельзя.")
return return
} }
// Only accept a user the machine itself reports, so a typo (or an agent too // Only accept a user the machine itself reports, so a typo (or an agent too
@@ -299,7 +299,7 @@ func (s *Server) handleAgentAddUser(w http.ResponseWriter, r *http.Request) {
return return
} }
if !s.online(a) { if !s.online(a) {
editUserRedirect(w, r, id, "uerr", "Машина офлайн — создать пользователя нельзя.") editUserRedirect(w, r, id, "uerr", "Машина офлайн - создать пользователя нельзя.")
return return
} }
name := strings.TrimSpace(r.FormValue("name")) name := strings.TrimSpace(r.FormValue("name"))
@@ -402,7 +402,7 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
}) })
} }
// POST /settings/tunnel — host and port agents use for the direct tunnel. // POST /settings/tunnel - host and port agents use for the direct tunnel.
func (s *Server) handleSetTunnel(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSetTunnel(w http.ResponseWriter, r *http.Request) {
if !checkCSRF(r) { if !checkCSRF(r) {
http.Error(w, "bad csrf", http.StatusForbidden) http.Error(w, "bad csrf", http.StatusForbidden)
+2 -2
View File
@@ -37,7 +37,7 @@ func (s *Server) handleFilesPage(w http.ResponseWriter, r *http.Request) {
s.render(w, "files.html", map[string]any{"Agent": a, "EditLimit": proto.FSReadCap}) s.render(w, "files.html", map[string]any{"Agent": a, "EditLimit": proto.FSReadCap})
} }
// POST /api/panel/agents/{id}/fs/{op} — run a file-manager operation on the // POST /api/panel/agents/{id}/fs/{op} - run a file-manager operation on the
// agent and return its result. The request body is the op's JSON args and is // agent and return its result. The request body is the op's JSON args and is
// forwarded verbatim to the agent. // forwarded verbatim to the agent.
func (s *Server) handleFSOp(w http.ResponseWriter, r *http.Request) { func (s *Server) handleFSOp(w http.ResponseWriter, r *http.Request) {
@@ -74,7 +74,7 @@ func (s *Server) handleFSOp(w http.ResponseWriter, r *http.Request) {
} }
} }
// GET /api/panel/agents/{id}/fs/download?path=... — stream a file as a download. // GET /api/panel/agents/{id}/fs/download?path=... - stream a file as a download.
func (s *Server) handleFSDownload(w http.ResponseWriter, r *http.Request) { func (s *Server) handleFSDownload(w http.ResponseWriter, r *http.Request) {
agentID := r.PathValue("id") agentID := r.PathValue("id")
p := r.URL.Query().Get("path") p := r.URL.Query().Get("path")
+5 -5
View File
@@ -103,12 +103,12 @@ func (s *Server) onlineAgent(w http.ResponseWriter, r *http.Request) (string, bo
// the task. // the task.
func oldAgentError(res proto.TaskResult) proto.TaskResult { func oldAgentError(res proto.TaskResult) proto.TaskResult {
if !res.OK && strings.Contains(res.Error, "неизвестная операция") { if !res.OK && strings.Contains(res.Error, "неизвестная операция") {
res.Error = "клиент не поддерживает эту функцию — обновите его (нужен 0.0.5+)" res.Error = "клиент не поддерживает эту функцию - обновите его (нужен 0.0.5+)"
} }
return res return res
} }
// GET /api/panel/agents/{id}/stat — CPU, RAM and disk of the machine right now. // 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) { func (s *Server) handleAgentStat(w http.ResponseWriter, r *http.Request) {
id, ok := s.onlineAgent(w, r) id, ok := s.onlineAgent(w, r)
if !ok { if !ok {
@@ -128,7 +128,7 @@ func (s *Server) handleAgentStat(w http.ResponseWriter, r *http.Request) {
writeJSON(w, e.res) writeJSON(w, e.res)
} }
// POST /api/panel/agents/{id}/speed — have the agent measure its link to us. // POST /api/panel/agents/{id}/speed - have the agent measure its link to us.
func (s *Server) handleAgentSpeed(w http.ResponseWriter, r *http.Request) { func (s *Server) handleAgentSpeed(w http.ResponseWriter, r *http.Request) {
id, ok := s.onlineAgent(w, r) id, ok := s.onlineAgent(w, r)
if !ok { if !ok {
@@ -150,7 +150,7 @@ var speedBlock = sync.OnceValue(func() []byte {
return b return b
}) })
// GET /api/agent/speed/down?bytes=N — stream N bytes (default: as much as fits // 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. // 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) { func (s *Server) handleSpeedDown(w http.ResponseWriter, r *http.Request) {
if s.authenticateAgent(r) == nil { if s.authenticateAgent(r) == nil {
@@ -182,7 +182,7 @@ func (s *Server) handleSpeedDown(w http.ResponseWriter, r *http.Request) {
} }
} }
// POST /api/agent/speed/up — swallow the agent's upload. // POST /api/agent/speed/up - swallow the agent's upload.
func (s *Server) handleSpeedUp(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSpeedUp(w http.ResponseWriter, r *http.Request) {
if s.authenticateAgent(r) == nil { if s.authenticateAgent(r) == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized) http.Error(w, "unauthorized", http.StatusUnauthorized)
+4 -4
View File
@@ -12,7 +12,7 @@ import (
// same transport the agent uses), so it works through any reverse proxy without // same transport the agent uses), so it works through any reverse proxy without
// WebSocket upgrade support. // WebSocket upgrade support.
// POST /api/panel/term/{agentID}/open — create a terminal session. // POST /api/panel/term/{agentID}/open - create a terminal session.
func (s *Server) handlePanelOpen(w http.ResponseWriter, r *http.Request) { func (s *Server) handlePanelOpen(w http.ResponseWriter, r *http.Request) {
agentID := r.PathValue("agentID") agentID := r.PathValue("agentID")
agent, err := s.db.GetAgent(agentID) agent, err := s.db.GetAgent(agentID)
@@ -28,7 +28,7 @@ func (s *Server) handlePanelOpen(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]string{"session_id": sess.ID}) writeJSON(w, map[string]string{"session_id": sess.ID})
} }
// GET /api/panel/term/{sid}/out — long-poll for PTY output. // GET /api/panel/term/{sid}/out - long-poll for PTY output.
func (s *Server) handlePanelOut(w http.ResponseWriter, r *http.Request) { func (s *Server) handlePanelOut(w http.ResponseWriter, r *http.Request) {
sess := s.hub.Session(r.PathValue("sid")) sess := s.hub.Session(r.PathValue("sid"))
if sess == nil { if sess == nil {
@@ -51,7 +51,7 @@ type panelInMsg struct {
Rows uint16 `json:"rows,omitempty"` Rows uint16 `json:"rows,omitempty"`
} }
// POST /api/panel/term/{sid}/in — send keystrokes/resize to the agent PTY. // POST /api/panel/term/{sid}/in - send keystrokes/resize to the agent PTY.
func (s *Server) handlePanelIn(w http.ResponseWriter, r *http.Request) { func (s *Server) handlePanelIn(w http.ResponseWriter, r *http.Request) {
sess := s.hub.Session(r.PathValue("sid")) sess := s.hub.Session(r.PathValue("sid"))
if sess == nil { if sess == nil {
@@ -72,7 +72,7 @@ func (s *Server) handlePanelIn(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
// POST /api/panel/term/{sid}/close — tear the session down. // POST /api/panel/term/{sid}/close - tear the session down.
func (s *Server) handlePanelClose(w http.ResponseWriter, r *http.Request) { func (s *Server) handlePanelClose(w http.ResponseWriter, r *http.Request) {
s.hub.CloseSession(r.PathValue("sid")) s.hub.CloseSession(r.PathValue("sid"))
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
+1 -1
View File
@@ -37,7 +37,7 @@ type taskEntry struct {
} }
// Session is one live byte stream between the panel and an agent (a PTY terminal // Session is one live byte stream between the panel and an agent (a PTY terminal
// or a raw TCP forward — the hub treats both identically). // or a raw TCP forward - the hub treats both identically).
type Session struct { type Session struct {
ID string ID string
AgentID string AgentID string
+2 -2
View File
@@ -26,8 +26,8 @@ type Options struct {
// nginx TLS); set false only for plain-HTTP local testing. // nginx TLS); set false only for plain-HTTP local testing.
SecureCookies bool SecureCookies bool
// TunnelAddr is the listen address for the direct TCP+UDP forward tunnel // TunnelAddr is the listen address for the direct TCP+UDP forward tunnel
// (default ":8446"). Empty disables direct forwarding. The port can be // Empty disables direct forwarding. The port can be overridden from the
// overridden from the settings page. // settings page.
TunnelAddr string TunnelAddr string
} }
+2 -2
View File
@@ -11,11 +11,11 @@ import (
"github.com/x-grk/longpolling-remote-access/internal/tunnel" "github.com/x-grk/longpolling-remote-access/internal/tunnel"
) )
// udpRelay carries UDP forwards over the direct 8446 UDP channel. The agent // udpRelay carries UDP forwards over the direct UDP channel. The agent
// registers its UDP return address; end-user datagrams arriving on a rule's // registers its UDP return address; end-user datagrams arriving on a rule's
// host port are relayed to the agent and replies are sent back. // host port are relayed to the agent and replies are sent back.
// //
// Datagram framing on the 8446 control socket: // Datagram framing on the tunnel control socket:
// //
// registration (agent->server): "R <agentID> <sig>" // registration (agent->server): "R <agentID> <sig>"
// data (both): 'D' | sid(4) | port(2) | payload // data (both): 'D' | sid(4) | port(2) | payload
+1 -1
View File
@@ -172,7 +172,7 @@ func (u *ufwManager) pruneShared() {
// allowForward opens the host ports a forward rule needs: its host port (bound // allowForward opens the host ports a forward rule needs: its host port (bound
// to the rule so it is revoked with it) plus the shared tunnel port for direct // to the rule so it is revoked with it) plus the shared tunnel port for direct
// tcp/udp modes (bound to 0 — shared across rules). A rule made "without ufw" // tcp/udp modes (bound to 0 - shared across rules). A rule made "without ufw"
// opens nothing, so nothing is revoked with it either. // opens nothing, so nothing is revoked with it either.
func (u *ufwManager) allowForward(rule ForwardRule) { func (u *ufwManager) allowForward(rule ForwardRule) {
if !u.available() { if !u.available() {
+3 -3
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Машины — LPRA</title> <title>Машины - LPRA</title>
<link rel="stylesheet" href="/static/app.css"> <link rel="stylesheet" href="/static/app.css">
</head> </head>
<body> <body>
@@ -49,7 +49,7 @@
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z"/></svg> <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z"/></svg>
</a> </a>
<form method="post" action="/agents/{{.ID}}/delete" style="display:inline" <form method="post" action="/agents/{{.ID}}/delete" style="display:inline"
onsubmit="return confirm('Удалить машину {{.Name}}? Сервис на ней продолжит работать, но перестанет быть виден. Чтобы снова подключиться — переустановите агента.')"> onsubmit="return confirm('Удалить машину {{.Name}}? Сервис на ней продолжит работать, но перестанет быть виден. Чтобы снова подключиться - переустановите агента.')">
<input type="hidden" name="csrf" value="{{$.CSRF}}"> <input type="hidden" name="csrf" value="{{$.CSRF}}">
<button class="btn danger small icon" type="submit" title="Удалить"> <button class="btn danger small icon" type="submit" title="Удалить">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg> <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>
@@ -63,7 +63,7 @@
</table> </table>
{{else}} {{else}}
<p class="muted">Пока нет подключённых машин. Добавьте первую в разделе <p class="muted">Пока нет подключённых машин. Добавьте первую в разделе
<a href="/settings">Настройки</a> — там одна команда для установки.</p> <a href="/settings">Настройки</a> - там одна команда для установки.</p>
{{end}} {{end}}
</div> </div>
</div> </div>
+4 -4
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Изменить машину — LPRA</title> <title>Изменить машину - LPRA</title>
<link rel="stylesheet" href="/static/app.css?v={{.ServerVersion}}-2"> <link rel="stylesheet" href="/static/app.css?v={{.ServerVersion}}-2">
</head> </head>
<body> <body>
@@ -55,7 +55,7 @@
<label style="color:var(--text); font-size:14px"> <label style="color:var(--text); font-size:14px">
<input type="checkbox" name="sudo" value="1"> Добавить в sudo <input type="checkbox" name="sudo" value="1"> Добавить в sudo
</label> </label>
<p class="muted" style="font-size:13px">Без пароля вход по паролю (и sudo) для него работать не будет — только терминал из панели.</p> <p class="muted" style="font-size:13px">Без пароля вход по паролю (и sudo) для него работать не будет - только терминал из панели.</p>
<button type="submit">Создать пользователя</button> <button type="submit">Создать пользователя</button>
</form> </form>
{{else}} {{else}}
@@ -64,7 +64,7 @@
{{else if .UsersErr}} {{else if .UsersErr}}
<p class="muted" style="font-size:13px">Список пользователей недоступен: {{.UsersErr}}.</p> <p class="muted" style="font-size:13px">Список пользователей недоступен: {{.UsersErr}}.</p>
{{else}} {{else}}
<p class="muted" style="font-size:13px">Машина офлайн — сменить пользователя можно, когда она на связи.</p> <p class="muted" style="font-size:13px">Машина офлайн - сменить пользователя можно, когда она на связи.</p>
{{end}} {{end}}
</div> </div>
@@ -90,7 +90,7 @@
<div class="card"> <div class="card">
<h2>Удаление</h2> <h2>Удаление</h2>
<p class="muted">Удаляет машину из панели. Сервис на самой машине продолжит работать, <p class="muted">Удаляет машину из панели. Сервис на самой машине продолжит работать,
но перестанет быть виден; чтобы подключиться снова — переустановите агента.</p> но перестанет быть виден; чтобы подключиться снова - переустановите агента.</p>
<form method="post" action="/agents/{{.Agent.ID}}/delete" <form method="post" action="/agents/{{.Agent.ID}}/delete"
onsubmit="return confirm('Удалить машину {{.Agent.Name}}?')"> onsubmit="return confirm('Удалить машину {{.Agent.Name}}?')">
<input type="hidden" name="csrf" value="{{.CSRF}}"> <input type="hidden" name="csrf" value="{{.CSRF}}">
+3 -3
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Файлы · {{.Agent.Name}} — LPRA</title> <title>Файлы · {{.Agent.Name}} - LPRA</title>
<link rel="stylesheet" href="/static/app.css"> <link rel="stylesheet" href="/static/app.css">
<style> <style>
.fm-bar { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-bottom:12px; } .fm-bar { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-bottom:12px; }
@@ -44,7 +44,7 @@
</head> </head>
<body> <body>
<header class="topbar"> <header class="topbar">
<div class="brand">LPRA · Файлы — {{.Agent.Name}} <span class="muted">({{.Agent.Hostname}})</span></div> <div class="brand">LPRA · Файлы - {{.Agent.Name}} <span class="muted">({{.Agent.Hostname}})</span></div>
<nav> <nav>
<a href="/">← Машины</a> <a href="/">← Машины</a>
<a href="/agents/{{.Agent.ID}}/terminal">Терминал</a> <a href="/agents/{{.Agent.ID}}/terminal">Терминал</a>
@@ -78,7 +78,7 @@
<tbody id="list"></tbody> <tbody id="list"></tbody>
</table> </table>
</div> </div>
<p class="muted" id="hint" style="font-size:13px">Перетащите файлы сюда для загрузки. Клик по файлу — просмотр (правка, если &lt; 2 КБ).</p> <p class="muted" id="hint" style="font-size:13px">Перетащите файлы сюда для загрузки. Клик по файлу - просмотр и правка.</p>
</div> </div>
<dialog id="viewer"> <dialog id="viewer">
+6 -6
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Порты · {{.Agent.Name}} — LPRA</title> <title>Порты · {{.Agent.Name}} - LPRA</title>
<link rel="stylesheet" href="/static/app.css"> <link rel="stylesheet" href="/static/app.css">
<style> <style>
.frow { display:flex; align-items:flex-end; gap:14px; flex-wrap:wrap; } .frow { display:flex; align-items:flex-end; gap:14px; flex-wrap:wrap; }
@@ -21,7 +21,7 @@
</head> </head>
<body> <body>
<header class="topbar"> <header class="topbar">
<div class="brand">LPRA · Порты — {{.Agent.Name}} <span class="muted">({{.Agent.Hostname}})</span></div> <div class="brand">LPRA · Порты - {{.Agent.Name}} <span class="muted">({{.Agent.Hostname}})</span></div>
<nav> <nav>
<a href="/">← Машины</a> <a href="/">← Машины</a>
<a href="/agents/{{.Agent.ID}}/terminal">Терминал</a> <a href="/agents/{{.Agent.ID}}/terminal">Терминал</a>
@@ -31,7 +31,7 @@
<div class="wrap"> <div class="wrap">
<h1>Проброс портов</h1> <h1>Проброс портов</h1>
<p class="muted">Порт сервиса на машине-клиенте становится доступен на порту хоста. <p class="muted">Порт сервиса на машине-клиенте становится доступен на порту хоста.
Клиент за NAT — трафик идёт исходящим подключением агента.</p> Клиент за NAT - трафик идёт исходящим подключением агента.</p>
{{if .Error}}<div class="err">{{.Error}}</div>{{end}} {{if .Error}}<div class="err">{{.Error}}</div>{{end}}
<div class="card"> <div class="card">
@@ -101,9 +101,9 @@
<div class="card"> <div class="card">
<h2>Режимы</h2> <h2>Режимы</h2>
<ul class="muted" style="font-size:14px; line-height:1.7"> <ul class="muted" style="font-size:14px; line-height:1.7">
<li><b>http</b> — туннель поверх HTTP long-poll (через адрес панели, порт {{.PanelPort}}). Работает везде, где доступна панель, но задержка выше.</li> <li><b>http</b> - туннель поверх HTTP long-poll (через адрес панели, порт {{.PanelPort}}). Работает везде, где доступна панель, но задержка выше.</li>
<li><b>tcp</b> — прямой TCP-мультиплексор через порт <b>{{.TunnelPort}}</b> хоста. Минимальная задержка, keep-alive, TCP_NODELAY. Нужно открыть {{.TunnelPort}}/tcp на хосте.</li> <li><b>tcp</b> - прямой TCP-мультиплексор через порт <b>{{.TunnelPort}}</b> хоста. Минимальная задержка, keep-alive, TCP_NODELAY. Нужно открыть {{.TunnelPort}}/tcp на хосте.</li>
<li><b>udp</b> — прямой UDP через порт <b>{{.TunnelPort}}</b> хоста. Нужно открыть {{.TunnelPort}}/udp.</li> <li><b>udp</b> - прямой UDP через порт <b>{{.TunnelPort}}</b> хоста. Нужно открыть {{.TunnelPort}}/udp.</li>
</ul> </ul>
<p class="muted" style="font-size:13px">Для tcp/udp агент держит исходящее соединение к <code>host:{{.TunnelPort}}</code>; несколько клиентов и портов мультиплексируются на нём.</p> <p class="muted" style="font-size:13px">Для tcp/udp агент держит исходящее соединение к <code>host:{{.TunnelPort}}</code>; несколько клиентов и портов мультиплексируются на нём.</p>
</div> </div>
+1 -1
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Вход — LPRA</title> <title>Вход - LPRA</title>
<link rel="stylesheet" href="/static/app.css"> <link rel="stylesheet" href="/static/app.css">
</head> </head>
<body> <body>
+4 -4
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Настройки — LPRA</title> <title>Настройки - LPRA</title>
<link rel="stylesheet" href="/static/app.css"> <link rel="stylesheet" href="/static/app.css">
</head> </head>
<body> <body>
@@ -56,7 +56,7 @@
<p class="muted">Прямой туннель отключён при запуске сервера (пустой <code>-tunnel</code> / <code>LPRA_TUNNEL_ADDR</code>).</p> <p class="muted">Прямой туннель отключён при запуске сервера (пустой <code>-tunnel</code> / <code>LPRA_TUNNEL_ADDR</code>).</p>
{{else}} {{else}}
<p class="muted">Адрес, к которому агенты подключаются для проброса в режимах tcp/udp. Агенты забирают его <p class="muted">Адрес, к которому агенты подключаются для проброса в режимах tcp/udp. Агенты забирают его
с панели сами — при подключении и при каждом опросе, так что смена применяется без переустановки. с панели сами - при подключении и при каждом опросе, так что смена применяется без переустановки.
Хост можно оставить пустым (тогда берётся хост из адреса панели) или указать IP/другое имя.</p> Хост можно оставить пустым (тогда берётся хост из адреса панели) или указать IP/другое имя.</p>
<form method="post" action="/settings/tunnel" class="row"> <form method="post" action="/settings/tunnel" class="row">
<input type="hidden" name="csrf" value="{{.CSRF}}"> <input type="hidden" name="csrf" value="{{.CSRF}}">
@@ -115,7 +115,7 @@
<div class="card"> <div class="card">
<h2>Firewall (ufw)</h2> <h2>Firewall (ufw)</h2>
{{if not .UfwInstalled}} {{if not .UfwInstalled}}
<p class="muted">ufw не установлен на хосте — интеграция недоступна.</p> <p class="muted">ufw не установлен на хосте - интеграция недоступна.</p>
{{else if not .UfwRunnable}} {{else if not .UfwRunnable}}
<p class="muted">ufw установлен, но сервер не может им управлять (нужны права root). <p class="muted">ufw установлен, но сервер не может им управлять (нужны права root).
Запустите сервер под root или настройте sudo для ufw.</p> Запустите сервер под root или настройте sudo для ufw.</p>
@@ -124,7 +124,7 @@
Статус ufw: {{if .UfwActive}}<span style="color:var(--ok)">активен</span>{{else}}<span style="color:var(--danger)">выключен</span>{{end}} Статус ufw: {{if .UfwActive}}<span style="color:var(--ok)">активен</span>{{else}}<span style="color:var(--danger)">выключен</span>{{end}}
· Интеграция: {{if .UfwEnabled}}<span style="color:var(--ok)">включена</span>{{else}}<span class="muted">выключена</span>{{end}} · Интеграция: {{if .UfwEnabled}}<span style="color:var(--ok)">включена</span>{{else}}<span class="muted">выключена</span>{{end}}
</p> </p>
<p class="muted" style="font-size:13px">При создании правила проброса порт открывается в ufw автоматически (для tcp/udp — также порт туннеля{{if .TunnelPort}} {{.TunnelPort}}{{end}}); для http-проброса открывается tcp-порт хоста. Порты правил, слушающих только 127.0.0.1, не открываются.</p> <p class="muted" style="font-size:13px">При создании правила проброса порт открывается в ufw автоматически (для tcp/udp - также порт туннеля{{if .TunnelPort}} {{.TunnelPort}}{{end}}); для http-проброса открывается tcp-порт хоста. Порты правил, слушающих только 127.0.0.1, не открываются.</p>
{{if .UfwError}}<div class="err">Последняя команда ufw завершилась ошибкой: <code>{{.UfwError}}</code></div>{{end}} {{if .UfwError}}<div class="err">Последняя команда ufw завершилась ошибкой: <code>{{.UfwError}}</code></div>{{end}}
<form method="post" action="/settings/ufw/toggle" style="display:inline"> <form method="post" action="/settings/ufw/toggle" style="display:inline">
<input type="hidden" name="csrf" value="{{.CSRF}}"> <input type="hidden" name="csrf" value="{{.CSRF}}">
+1 -1
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Настройка — LPRA</title> <title>Настройка - LPRA</title>
<link rel="stylesheet" href="/static/app.css"> <link rel="stylesheet" href="/static/app.css">
</head> </head>
<body> <body>
+6 -6
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Статус · {{.Agent.Name}} — LPRA</title> <title>Статус · {{.Agent.Name}} - LPRA</title>
<link rel="stylesheet" href="/static/app.css"> <link rel="stylesheet" href="/static/app.css">
<style> <style>
.meters { display:grid; grid-template-columns:repeat(auto-fit, minmax(240px, 1fr)); gap:20px; margin-bottom:20px; } .meters { display:grid; grid-template-columns:repeat(auto-fit, minmax(240px, 1fr)); gap:20px; margin-bottom:20px; }
@@ -23,7 +23,7 @@
</head> </head>
<body> <body>
<header class="topbar"> <header class="topbar">
<div class="brand">LPRA · Статус — {{.Agent.Name}} <span class="muted">({{.Agent.Hostname}})</span></div> <div class="brand">LPRA · Статус - {{.Agent.Name}} <span class="muted">({{.Agent.Hostname}})</span></div>
<nav> <nav>
<a href="/">← Машины</a> <a href="/">← Машины</a>
<a href="/agents/{{.Agent.ID}}/terminal">Терминал</a> <a href="/agents/{{.Agent.ID}}/terminal">Терминал</a>
@@ -37,17 +37,17 @@
<div class="meters"> <div class="meters">
<div class="card meter"> <div class="card meter">
<div class="head"><span class="name">Процессор</span><span class="val" id="cpu-val">—</span></div> <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="bar"><i id="cpu-bar"></i></div>
<div class="sub" id="cpu-sub"></div> <div class="sub" id="cpu-sub"></div>
</div> </div>
<div class="card meter"> <div class="card meter">
<div class="head"><span class="name">Оперативная память</span><span class="val" id="mem-val">—</span></div> <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="bar"><i id="mem-bar"></i></div>
<div class="sub" id="mem-sub"></div> <div class="sub" id="mem-sub"></div>
</div> </div>
<div class="card meter"> <div class="card meter">
<div class="head"><span class="name">Диск (/)</span><span class="val" id="disk-val">—</span></div> <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="bar"><i id="disk-bar"></i></div>
<div class="sub" id="disk-sub"></div> <div class="sub" id="disk-sub"></div>
</div> </div>
@@ -55,7 +55,7 @@
<div class="card"> <div class="card">
<h2>Скорость соединения</h2> <h2>Скорость соединения</h2>
<p class="muted">Клиент замеряет свой канал до хоста панели — тот же, по которому идут терминал, файлы и http-проброс. <p class="muted">Клиент замеряет свой канал до хоста панели - тот же, по которому идут терминал, файлы и http-проброс.
Замер занимает около 7 секунд и на это время нагружает канал.</p> Замер занимает около 7 секунд и на это время нагружает канал.</p>
<div class="row"> <div class="row">
<button id="speed-btn" type="button">Замерить скорость</button> <button id="speed-btn" type="button">Замерить скорость</button>
+3 -3
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Терминал · {{.Agent.Name}} — LPRA</title> <title>Терминал · {{.Agent.Name}} - LPRA</title>
<link rel="stylesheet" href="/static/app.css"> <link rel="stylesheet" href="/static/app.css">
<link rel="stylesheet" href="/static/xterm.css"> <link rel="stylesheet" href="/static/xterm.css">
<style> <style>
@@ -13,7 +13,7 @@
</head> </head>
<body> <body>
<header class="topbar"> <header class="topbar">
<div class="brand">LPRA · Терминал — {{.Agent.Name}} <span class="muted">({{.Agent.TerminalUser}}@{{.Agent.Hostname}})</span></div> <div class="brand">LPRA · Терминал - {{.Agent.Name}} <span class="muted">({{.Agent.TerminalUser}}@{{.Agent.Hostname}})</span></div>
<nav> <nav>
<a href="/">← Машины</a> <a href="/">← Машины</a>
<a href="/agents/{{.Agent.ID}}/files">Файлы</a> <a href="/agents/{{.Agent.ID}}/files">Файлы</a>
@@ -78,7 +78,7 @@
setStatus('ошибка открытия (' + r.status + ')', 'st-err'); setStatus('ошибка открытия (' + r.status + ')', 'st-err');
term.write('\r\n\x1b[31m[не удалось открыть сессию: HTTP ' + r.status + ']\x1b[0m\r\n'); term.write('\r\n\x1b[31m[не удалось открыть сессию: HTTP ' + r.status + ']\x1b[0m\r\n');
if (body) term.write(body.slice(0, 300).replace(/\n/g, '\r\n') + '\r\n'); if (body) term.write(body.slice(0, 300).replace(/\n/g, '\r\n') + '\r\n');
if (r.status === 404) term.write('\x1b[33mМашина не найдена на сервере — возможно, устаревший бинарник сервера без маршрута /open.\x1b[0m\r\n'); if (r.status === 404) term.write('\x1b[33mМашина не найдена на сервере - возможно, устаревший бинарник сервера без маршрута /open.\x1b[0m\r\n');
return; return;
} }
const j = await r.json(); const j = await r.json();
+1 -1
View File
@@ -1,5 +1,5 @@
// Package tunnel defines the framed multiplexing protocol used for DIRECT port // Package tunnel defines the framed multiplexing protocol used for DIRECT port
// forwarding over the dedicated host port (default 8446). Unlike the HTTP // forwarding over the dedicated host port (DefaultPort). Unlike the HTTP
// long-poll forward, this is a raw TCP stream the agent dials outbound; it // long-poll forward, this is a raw TCP stream the agent dials outbound; it
// carries many concurrent forwarded connections (streams) for one agent and // carries many concurrent forwarded connections (streams) for one agent and
// has far lower latency than long-polling. // has far lower latency than long-polling.