Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e5718b397 | ||
|
|
c675bb0ec7 | ||
|
|
a02fdc01b0 | ||
|
|
8ba2dd1f1b | ||
|
|
171e14176a | ||
|
|
86cb0226ab | ||
|
|
7f4d645f19 | ||
|
|
f3cb7701f2 | ||
|
|
328ade651c | ||
|
|
5e6e0b8a3a | ||
|
|
bcfe909aaa | ||
|
|
08fc2d79a8 | ||
|
|
7cb9da8569 | ||
|
|
93673d3570 | ||
|
|
3dde07f403 |
No files matched your search
@@ -1,8 +1,8 @@
|
|||||||
# LPRA — LongPolling Remote Access
|
# LPRA - LongPolling Remote Access
|
||||||
|
|
||||||
Лёгкий self-hosted сервис для доступа к Linux-машинам **без белого IP** (за NAT).
|
Лёгкий self-hosted сервис для доступа к Linux-машинам **без белого IP** (за NAT).
|
||||||
Агент на клиенте сам исходяще подключается к серверу по HTTP long-polling; админ
|
Агент на клиенте сам исходяще подключается к серверу по HTTP long-polling; админ
|
||||||
в веб-панели видит список машин и открывает по каждой **root-терминал в браузере**.
|
в веб-панели видит список машин и открывает по каждой **терминал в браузере** (по умолчанию от root, пользователя можно сменить).
|
||||||
|
|
||||||
```
|
```
|
||||||
браузер ──WS──> [nginx :8445 TLS] ──> [Go server 127.0.0.1:8091] <──long-poll── агент (root, за NAT)
|
браузер ──WS──> [nginx :8445 TLS] ──> [Go server 127.0.0.1:8091] <──long-poll── агент (root, за NAT)
|
||||||
@@ -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,15 +37,38 @@
|
|||||||
```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),
|
||||||
|
режимы `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).
|
- Агенты: общий 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" />
|
<img width="1256" height="709" alt="image" src="https://github.com/user-attachments/assets/5f0b30f1-d5e3-4b62-817e-4041641df6e3" />
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
0.0.1
|
0.0.5
|
||||||
@@ -5,10 +5,9 @@ set -eu
|
|||||||
|
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
VERSION="${VERSION:-$(cat VERSION.txt 2>/dev/null || echo 0.0.0)}"
|
# The version comes from VERSION.txt, which is embedded into every binary.
|
||||||
PKG="github.com/x-grk/longpolling-remote-access"
|
LDFLAGS="-s -w"
|
||||||
LDFLAGS="-s -w -X $PKG/internal/server.Version=$VERSION -X $PKG/internal/agent.Version=$VERSION"
|
echo ">> version $(cat VERSION.txt)"
|
||||||
echo ">> version $VERSION"
|
|
||||||
|
|
||||||
mkdir -p dist bin
|
mkdir -p dist bin
|
||||||
|
|
||||||
|
|||||||
+9
-4
@@ -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() {
|
||||||
@@ -23,6 +25,8 @@ func main() {
|
|||||||
Name: os.Getenv("AGENT_NAME"),
|
Name: os.Getenv("AGENT_NAME"),
|
||||||
StatePath: envOr("STATE_PATH", "/etc/lpagent/state.json"),
|
StatePath: envOr("STATE_PATH", "/etc/lpagent/state.json"),
|
||||||
TunnelAddr: tunnelAddr(host),
|
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 == "" {
|
if cfg.EnrollSecret == "" {
|
||||||
// Only required for the very first run (before enrollment).
|
// Only required for the very first run (before enrollment).
|
||||||
@@ -46,9 +50,10 @@ 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.
|
||||||
// TUNNEL_ADDR overrides it entirely; TUNNEL_PORT overrides just the port.
|
// It is only the starting point: the agent follows the host and port set in
|
||||||
// Set TUNNEL_ADDR=off to disable the direct tunnel.
|
// 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 {
|
func tunnelAddr(hostURL string) string {
|
||||||
if v := os.Getenv("TUNNEL_ADDR"); v != "" {
|
if v := os.Getenv("TUNNEL_ADDR"); v != "" {
|
||||||
if v == "off" {
|
if v == "off" {
|
||||||
@@ -60,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
@@ -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()
|
||||||
|
|||||||
+17
-20
@@ -1,24 +1,24 @@
|
|||||||
# systemd unit for the LPRA host (server).
|
# systemd unit for the LPRA host (server).
|
||||||
#
|
#
|
||||||
# Unlike the agent, the server does NOT need root: it listens on a high local
|
# The server runs as root so that the panel can open forwarded ports in ufw;
|
||||||
# port (127.0.0.1:8091) behind nginx, so run it as an unprivileged user.
|
# it listens on a high local port (127.0.0.1:8091) behind nginx.
|
||||||
#
|
#
|
||||||
# Install:
|
# Install:
|
||||||
# sudo useradd --system --home /opt/lpra --shell /usr/sbin/nologin lpra
|
# sudo mkdir -p /opt/lpra
|
||||||
# sudo mkdir -p /opt/lpra /var/lib/lpra
|
|
||||||
# sudo cp bin/lpra-server /opt/lpra/
|
# sudo cp bin/lpra-server /opt/lpra/
|
||||||
# sudo cp -r dist /opt/lpra/dist # agent binaries served at /download/
|
# 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 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
|
||||||
#
|
#
|
||||||
# Config overrides can go in /etc/lpra-server.env (see EnvironmentFile below),
|
# Direct port forwarding (tcp/udp modes) uses the tunnel port on the host, which
|
||||||
# e.g. LPRA_ADDR=127.0.0.1:8091
|
# 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
|
# ufw integration (the panel opening forwarded ports itself) needs root and a
|
||||||
# the firewall: ufw allow 8446/tcp ; ufw allow 8446/udp
|
# writable /etc/ufw and /run - see ReadWritePaths below.
|
||||||
# (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.
|
|
||||||
|
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=LPRA remote access host (admin panel + agent rendezvous)
|
Description=LPRA remote access host (admin panel + agent rendezvous)
|
||||||
@@ -26,17 +26,11 @@ After=network-online.target
|
|||||||
Wants=network-online.target
|
Wants=network-online.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
|
||||||
User=lpra
|
|
||||||
Group=lpra
|
|
||||||
WorkingDirectory=/opt/lpra
|
WorkingDirectory=/opt/lpra
|
||||||
|
|
||||||
# Optional environment overrides; "-" means the file is not required.
|
|
||||||
EnvironmentFile=-/etc/lpra-server.env
|
|
||||||
|
|
||||||
ExecStart=/opt/lpra/lpra-server \
|
ExecStart=/opt/lpra/lpra-server \
|
||||||
-addr 127.0.0.1:8091 \
|
-addr 127.0.0.1:8091 \
|
||||||
-db /var/lib/lpra/lpra.db \
|
-db /opt/lpra/lpra.db \
|
||||||
-dist /opt/lpra/dist
|
-dist /opt/lpra/dist
|
||||||
|
|
||||||
Restart=always
|
Restart=always
|
||||||
@@ -51,8 +45,11 @@ PrivateDevices=true
|
|||||||
ProtectKernelTunables=true
|
ProtectKernelTunables=true
|
||||||
ProtectControlGroups=true
|
ProtectControlGroups=true
|
||||||
RestrictSUIDSGID=true
|
RestrictSUIDSGID=true
|
||||||
# The server only needs to write its SQLite database here.
|
# The SQLite database, plus what ufw writes: its rules in /etc/ufw and the
|
||||||
ReadWritePaths=/var/lib/lpra
|
# 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]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
@@ -5,6 +5,7 @@ go 1.26
|
|||||||
require (
|
require (
|
||||||
github.com/creack/pty v1.1.21
|
github.com/creack/pty v1.1.21
|
||||||
golang.org/x/crypto v0.31.0
|
golang.org/x/crypto v0.31.0
|
||||||
|
golang.org/x/sys v0.28.0
|
||||||
modernc.org/sqlite v1.24.0
|
modernc.org/sqlite v1.24.0
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -13,7 +14,6 @@ require (
|
|||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.16 // indirect
|
github.com/mattn/go-isatty v0.0.16 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
golang.org/x/sys v0.28.0 // indirect
|
|
||||||
modernc.org/libc v1.22.5 // indirect
|
modernc.org/libc v1.22.5 // indirect
|
||||||
modernc.org/mathutil v1.5.0 // indirect
|
modernc.org/mathutil v1.5.0 // indirect
|
||||||
modernc.org/memory v1.5.0 // indirect
|
modernc.org/memory v1.5.0 // indirect
|
||||||
|
|||||||
+85
-11
@@ -13,17 +13,19 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
lpra "github.com/x-grk/longpolling-remote-access"
|
||||||
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Version is the agent build version, injected at compile time via -ldflags
|
// Version is the agent build version (see VERSION.txt).
|
||||||
// (-X .../internal/agent.Version=...).
|
var Version = lpra.Version
|
||||||
var Version = "dev"
|
|
||||||
|
|
||||||
// Config is the agent's runtime configuration (from env / config.env).
|
// Config is the agent's runtime configuration (from env / config.env).
|
||||||
type Config struct {
|
type Config struct {
|
||||||
@@ -31,7 +33,10 @@ 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 /
|
||||||
|
// TUNNEL_PORT) instead of following the address set in the panel.
|
||||||
|
TunnelPinned bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type state struct {
|
type state struct {
|
||||||
@@ -45,6 +50,11 @@ type Agent struct {
|
|||||||
http *http.Client
|
http *http.Client
|
||||||
state state
|
state state
|
||||||
pollCount int
|
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.
|
// 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)
|
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.
|
// Direct forward tunnel (low-latency TCP mux + UDP). Its address comes
|
||||||
if a.cfg.TunnelAddr != "" {
|
// from the panel; fall back to the configured one when the server sends
|
||||||
go a.runTunnel(ctx)
|
// none (tunnel port left at the default on an older server).
|
||||||
go a.runUDP(ctx)
|
if a.cfg.TunnelPinned || !a.fetchConfig(ctx) {
|
||||||
|
a.setTunnelAddr(ctx, a.cfg.TunnelAddr)
|
||||||
}
|
}
|
||||||
|
|
||||||
backoff := time.Second
|
backoff := time.Second
|
||||||
@@ -120,6 +131,7 @@ func (a *Agent) enroll(ctx context.Context) error {
|
|||||||
OS: runtime.GOOS,
|
OS: runtime.GOOS,
|
||||||
Arch: runtime.GOARCH,
|
Arch: runtime.GOARCH,
|
||||||
Version: Version,
|
Version: Version,
|
||||||
|
User: currentUser(),
|
||||||
})
|
})
|
||||||
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, a.cfg.Host+"/api/agent/enroll", bytes.NewReader(body))
|
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, a.cfg.Host+"/api/agent/enroll", bytes.NewReader(body))
|
||||||
req.Header.Set("X-Enroll-Secret", a.cfg.EnrollSecret)
|
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 {
|
func (a *Agent) pollOnce(ctx context.Context) error {
|
||||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, a.cfg.Host+"/api/agent/poll", nil)
|
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, a.cfg.Host+"/api/agent/poll", nil)
|
||||||
a.authHeaders(req)
|
a.authHeaders(req)
|
||||||
// Report the version occasionally (not every poll), so the panel can flag
|
// Report the version and our OS user occasionally (not every poll), so the
|
||||||
// outdated agents without extra traffic.
|
// panel can flag outdated agents without extra traffic.
|
||||||
a.pollCount++
|
a.pollCount++
|
||||||
if a.pollCount%20 == 1 {
|
if a.pollCount%20 == 1 {
|
||||||
req.Header.Set("X-Agent-Version", Version)
|
req.Header.Set("X-Agent-Version", Version)
|
||||||
|
req.Header.Set("X-Agent-User", currentUser())
|
||||||
}
|
}
|
||||||
resp, err := a.http.Do(req)
|
resp, err := a.http.Do(req)
|
||||||
if err != nil {
|
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 {
|
if err := json.NewDecoder(resp.Body).Decode(&msg); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
a.applyTunnelHeader(ctx, resp.Header)
|
||||||
// Dispatch in the background so we keep polling for more work.
|
// Dispatch in the background so we keep polling for more work.
|
||||||
switch msg.Type {
|
switch msg.Type {
|
||||||
case proto.CtrlOpenTerminal:
|
case proto.CtrlOpenTerminal:
|
||||||
go a.runTerminal(ctx, msg.SessionID)
|
go a.runTerminal(ctx, msg.SessionID, msg.User)
|
||||||
case proto.CtrlOpenForward:
|
case proto.CtrlOpenForward:
|
||||||
go a.runForward(ctx, msg.SessionID, msg.Port)
|
go a.runForward(ctx, msg.SessionID, msg.Port)
|
||||||
case proto.CtrlTask:
|
case proto.CtrlTask:
|
||||||
@@ -178,6 +192,66 @@ func (a *Agent) pollOnce(ctx context.Context) error {
|
|||||||
return nil
|
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) {
|
func (a *Agent) authHeaders(req *http.Request) {
|
||||||
req.Header.Set("X-Agent-Id", a.state.AgentID)
|
req.Header.Set("X-Agent-Id", a.state.AgentID)
|
||||||
req.Header.Set("Authorization", "Bearer "+a.state.Token)
|
req.Header.Set("Authorization", "Bearer "+a.state.Token)
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ import (
|
|||||||
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||||
)
|
)
|
||||||
|
|
||||||
// fsReadCap bounds how many bytes a view/read returns (5 MiB).
|
// fsReadCap bounds how many bytes a view/read returns.
|
||||||
const fsReadCap = 5 * 1024 * 1024
|
const fsReadCap = proto.FSReadCap
|
||||||
|
|
||||||
// fsDownloadCap bounds a browser download (kept under the 64 MiB result/body
|
// fsDownloadCap bounds a browser download (kept under the 64 MiB result/body
|
||||||
// limit once base64-encoded).
|
// limit once base64-encoded).
|
||||||
|
|||||||
@@ -15,14 +15,17 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// runTerminal spawns a PTY and bridges it to the session.
|
// runTerminal spawns a PTY and bridges it to the session.
|
||||||
func (a *Agent) runTerminal(parent context.Context, sid string) {
|
// user is the OS user to run the shell as ("" = the agent's own user).
|
||||||
term, err := StartPTY()
|
func (a *Agent) runTerminal(parent context.Context, sid, user string) {
|
||||||
|
term, err := StartPTY(user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("term %s: start pty: %v", sid, err)
|
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})
|
a.postOut(context.Background(), sid, proto.OutMessage{Exit: true})
|
||||||
return
|
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)
|
a.bridgeStream(parent, sid, term, term.Resize)
|
||||||
log.Printf("term %s: ended", sid)
|
log.Printf("term %s: ended", sid)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -17,9 +17,12 @@ func (a *Agent) runTask(ctx context.Context, taskID string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
var res proto.TaskResult
|
var res proto.TaskResult
|
||||||
if env.Type == proto.TaskSysUpdate {
|
switch env.Type {
|
||||||
|
case proto.TaskSysUpdate:
|
||||||
res = a.doUpdate()
|
res = a.doUpdate()
|
||||||
} else {
|
case proto.TaskSysSpeed:
|
||||||
|
res = a.doSpeed(ctx)
|
||||||
|
default:
|
||||||
res = a.execTask(env)
|
res = a.execTask(env)
|
||||||
}
|
}
|
||||||
a.postResult(ctx, taskID, res)
|
a.postResult(ctx, taskID, res)
|
||||||
@@ -54,9 +57,10 @@ func (a *Agent) postResult(ctx context.Context, taskID string, res proto.TaskRes
|
|||||||
resp.Body.Close()
|
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 {
|
func (a *Agent) execTask(env proto.TaskEnvelope) proto.TaskResult {
|
||||||
data, err := dispatchFS(env)
|
data, err := dispatchTask(env)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return proto.TaskResult{Error: err.Error()}
|
return proto.TaskResult{Error: err.Error()}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
|
||||||
@@ -13,14 +14,30 @@ type PTY struct {
|
|||||||
f *os.File
|
f *os.File
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartPTY launches the shell attached to a new PTY. The agent runs as root
|
// StartPTY launches a login shell attached to a new PTY. With user empty (or
|
||||||
// (systemd User=root), so this is a root shell by design.
|
// equal to the agent's own user) it is the agent's shell - root by default,
|
||||||
func StartPTY() (*PTY, error) {
|
// since the agent runs as a root systemd service. Any other user is entered via
|
||||||
shell := "/bin/bash"
|
// `su -l`, which only a root agent can do without a password.
|
||||||
if _, err := os.Stat(shell); err != nil {
|
func StartPTY(user string) (*PTY, error) {
|
||||||
shell = "/bin/sh"
|
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")
|
cmd.Env = append(os.Environ(), "TERM=xterm-256color")
|
||||||
f, err := pty.Start(cmd)
|
f, err := pty.Start(cmd)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+17
-13
@@ -15,12 +15,12 @@ import (
|
|||||||
|
|
||||||
// --- direct TCP mux tunnel ---
|
// --- direct TCP mux tunnel ---
|
||||||
|
|
||||||
// runTunnel keeps a persistent connection to the host's 8446 tunnel, dialing
|
// runTunnel keeps a persistent connection to the host's tunnel at addr, dialing
|
||||||
// client-side ports on demand for "tcp" forward rules.
|
// client-side ports on demand for "tcp" forward rules, until ctx is cancelled.
|
||||||
func (a *Agent) runTunnel(ctx context.Context) {
|
func (a *Agent) runTunnel(ctx context.Context, addr string) {
|
||||||
backoff := time.Second
|
backoff := time.Second
|
||||||
for ctx.Err() == nil {
|
for ctx.Err() == nil {
|
||||||
if err := a.tunnelOnce(ctx); err != nil {
|
if err := a.tunnelOnce(ctx, addr); err != nil {
|
||||||
select {
|
select {
|
||||||
case <-time.After(backoff):
|
case <-time.After(backoff):
|
||||||
case <-ctx.Done():
|
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}
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer conn.Close()
|
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 {
|
if tc, ok := conn.(*net.TCPConn); ok {
|
||||||
_ = tc.SetNoDelay(true)
|
_ = tc.SetNoDelay(true)
|
||||||
}
|
}
|
||||||
@@ -65,7 +67,7 @@ func (a *Agent) tunnelOnce(ctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
_ = conn.SetDeadline(time.Time{})
|
_ = 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{}}
|
tc := &tunClient{conn: conn, streams: map[uint32]*agentStream{}}
|
||||||
return tc.readLoop()
|
return tc.readLoop()
|
||||||
@@ -194,12 +196,12 @@ func (t *tunClient) shutdown() {
|
|||||||
|
|
||||||
// --- direct UDP forwarding ---
|
// --- 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.
|
// 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
|
backoff := time.Second
|
||||||
for ctx.Err() == nil {
|
for ctx.Err() == nil {
|
||||||
if err := a.udpOnce(ctx); err != nil {
|
if err := a.udpOnce(ctx, addr); err != nil {
|
||||||
select {
|
select {
|
||||||
case <-time.After(backoff):
|
case <-time.After(backoff):
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
@@ -214,8 +216,8 @@ func (a *Agent) runUDP(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Agent) udpOnce(ctx context.Context) error {
|
func (a *Agent) udpOnce(ctx context.Context, addr string) error {
|
||||||
raddr, err := net.ResolveUDPAddr("udp", a.cfg.TunnelAddr)
|
raddr, err := net.ResolveUDPAddr("udp", addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -224,6 +226,8 @@ func (a *Agent) udpOnce(ctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer conn.Close()
|
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)
|
key := tunnel.AuthKey(a.state.Token)
|
||||||
register := func() {
|
register := func() {
|
||||||
@@ -268,7 +272,7 @@ func (a *Agent) udpOnce(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type udpClient struct {
|
type udpClient struct {
|
||||||
server *net.UDPConn // connection back to host:8446
|
server *net.UDPConn // connection back to the host tunnel
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
dials map[uint32]*net.UDPConn // sid -> client-side socket
|
dials map[uint32]*net.UDPConn // sid -> client-side socket
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ type EnrollRequest struct {
|
|||||||
OS string `json:"os"`
|
OS string `json:"os"`
|
||||||
Arch string `json:"arch"`
|
Arch string `json:"arch"`
|
||||||
Version string `json:"version"`
|
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.
|
// 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"`
|
SessionID string `json:"session_id,omitempty"`
|
||||||
Port int `json:"port,omitempty"` // CtrlOpenForward: client-side port to dial
|
Port int `json:"port,omitempty"` // CtrlOpenForward: client-side port to dial
|
||||||
TaskID string `json:"task_id,omitempty"` // CtrlTask
|
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).
|
// 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
|
TaskFSDownload = "fs.download" // read a whole file (larger cap) for browser download
|
||||||
|
|
||||||
TaskSysUpdate = "sys.update" // download the latest agent binary and restart
|
TaskSysUpdate = "sys.update" // download the latest agent binary and restart
|
||||||
|
TaskSysUsers = "sys.users" // report the agent's user and the login-capable users
|
||||||
|
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.
|
// TaskEnvelope is what the agent fetches for a task id.
|
||||||
type TaskEnvelope struct {
|
type TaskEnvelope struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
@@ -144,3 +154,35 @@ type FSReadResult struct {
|
|||||||
Truncated bool `json:"truncated"`
|
Truncated bool `json:"truncated"`
|
||||||
Binary bool `json:"binary"`
|
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"`
|
||||||
|
}
|
||||||
@@ -24,6 +24,16 @@ func (s *Server) authenticateAgent(r *http.Request) *Agent {
|
|||||||
return a
|
return a
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// online reports whether the agent is connected right now: the hub knows from
|
||||||
|
// its control polls. Right after a start it cannot tell yet, so the last poll
|
||||||
|
// recorded in the database decides.
|
||||||
|
func (s *Server) online(a *Agent) bool {
|
||||||
|
if on, ok := s.hub.Online(a.ID); ok {
|
||||||
|
return on
|
||||||
|
}
|
||||||
|
return a.recentlySeen()
|
||||||
|
}
|
||||||
|
|
||||||
func bearerToken(r *http.Request) string {
|
func bearerToken(r *http.Request) string {
|
||||||
h := r.Header.Get("Authorization")
|
h := r.Header.Get("Authorization")
|
||||||
if strings.HasPrefix(h, "Bearer ") {
|
if strings.HasPrefix(h, "Bearer ") {
|
||||||
@@ -34,7 +44,7 @@ func bearerToken(r *http.Request) string {
|
|||||||
|
|
||||||
// enroll creates a new agent record for a valid enroll secret and returns the
|
// 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).
|
// 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]
|
id = randToken()[:16]
|
||||||
token = randToken()
|
token = randToken()
|
||||||
a := Agent{
|
a := Agent{
|
||||||
@@ -44,6 +54,7 @@ func (s *Server) enroll(name, hostname, os, arch, version string) (id, token str
|
|||||||
OS: os,
|
OS: os,
|
||||||
Arch: arch,
|
Arch: arch,
|
||||||
Version: version,
|
Version: version,
|
||||||
|
RunUser: runUser,
|
||||||
TokenHash: hashToken(token),
|
TokenHash: hashToken(token),
|
||||||
}
|
}
|
||||||
if err = s.db.CreateAgent(a); err != nil {
|
if err = s.db.CreateAgent(a); err != nil {
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ package server
|
|||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
lpra "github.com/x-grk/longpolling-remote-access"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config keys stored in the config table.
|
// Config keys stored in the config table.
|
||||||
@@ -12,15 +15,17 @@ const (
|
|||||||
cfgSetupDone = "setup_done"
|
cfgSetupDone = "setup_done"
|
||||||
cfgInstallCode = "install_code"
|
cfgInstallCode = "install_code"
|
||||||
cfgUfwEnabled = "ufw_enabled"
|
cfgUfwEnabled = "ufw_enabled"
|
||||||
|
cfgTunnelHost = "tunnel_host"
|
||||||
|
cfgTunnelPort = "tunnel_port"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DefaultHostAddress is the public URL agents dial by default. It is baked into
|
// 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.
|
// the rendered install.sh and can be changed from the settings page.
|
||||||
const DefaultHostAddress = "https://REDACTED:8445"
|
const DefaultHostAddress = "https://REDACTED:8445"
|
||||||
|
|
||||||
// Version is the server build version, injected at compile time via -ldflags
|
// Version is the server build version (see VERSION.txt). It is the newest
|
||||||
// (-X .../internal/server.Version=...). It is the newest version agents update to.
|
// version agents update to.
|
||||||
var Version = "dev"
|
var Version = lpra.Version
|
||||||
|
|
||||||
// ensureDefaults seeds the host address and a random enroll secret on first boot.
|
// ensureDefaults seeds the host address and a random enroll secret on first boot.
|
||||||
func (db *DB) ensureDefaults() error {
|
func (db *DB) ensureDefaults() error {
|
||||||
@@ -86,6 +91,24 @@ func (db *DB) SetUfwEnabled(on bool) error {
|
|||||||
return db.SetConfig(cfgUfwEnabled, "0")
|
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 {
|
func (db *DB) SetupDone() bool {
|
||||||
v, _ := db.GetConfig(cfgSetupDone)
|
v, _ := db.GetConfig(cfgSetupDone)
|
||||||
return v == "1"
|
return v == "1"
|
||||||
|
|||||||
+43
-21
@@ -59,6 +59,8 @@ CREATE TABLE IF NOT EXISTS agents (
|
|||||||
arch TEXT NOT NULL,
|
arch TEXT NOT NULL,
|
||||||
token_hash TEXT NOT NULL,
|
token_hash TEXT NOT NULL,
|
||||||
version TEXT NOT NULL DEFAULT '',
|
version TEXT NOT NULL DEFAULT '',
|
||||||
|
run_user TEXT NOT NULL DEFAULT '',
|
||||||
|
login_user TEXT NOT NULL DEFAULT '',
|
||||||
enrolled_at INTEGER NOT NULL,
|
enrolled_at INTEGER NOT NULL,
|
||||||
last_seen 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 forward_rules ADD COLUMN proto TEXT NOT NULL DEFAULT 'http'`,
|
||||||
`ALTER TABLE agents ADD COLUMN version TEXT NOT NULL DEFAULT ''`,
|
`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 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") {
|
if _, err := db.sql.Exec(alter); err != nil && !strings.Contains(err.Error(), "duplicate column") {
|
||||||
return err
|
return err
|
||||||
@@ -222,20 +227,30 @@ type Agent struct {
|
|||||||
Arch string
|
Arch string
|
||||||
TokenHash string
|
TokenHash string
|
||||||
Version 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
|
LastSeen time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// Online reports whether the agent polled recently enough to be considered up.
|
// TerminalUser is the OS user a terminal on this agent runs as. Agents are
|
||||||
func (a Agent) Online() bool {
|
// 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
|
return time.Since(a.LastSeen) < 45*time.Second
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) CreateAgent(a Agent) error {
|
func (db *DB) CreateAgent(a Agent) error {
|
||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
_, err := db.sql.Exec(
|
_, err := db.sql.Exec(
|
||||||
`INSERT INTO agents(id, name, hostname, os, arch, token_hash, version, enrolled_at, last_seen)
|
`INSERT INTO agents(id, name, hostname, os, arch, token_hash, version, run_user, enrolled_at, last_seen)
|
||||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
a.ID, a.Name, a.Hostname, a.OS, a.Arch, a.TokenHash, a.Version, now, now)
|
a.ID, a.Name, a.Hostname, a.OS, a.Arch, a.TokenHash, a.Version, a.RunUser, now, now)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,8 +258,8 @@ func (db *DB) GetAgent(id string) (*Agent, error) {
|
|||||||
a := &Agent{}
|
a := &Agent{}
|
||||||
var last int64
|
var last int64
|
||||||
err := db.sql.QueryRow(
|
err := db.sql.QueryRow(
|
||||||
`SELECT id, name, hostname, os, arch, token_hash, version, last_seen FROM agents WHERE id = ?`, id).
|
`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, &last)
|
Scan(&a.ID, &a.Name, &a.Hostname, &a.OS, &a.Arch, &a.TokenHash, &a.Version, &a.RunUser, &a.LoginUser, &last)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -257,7 +272,7 @@ func (db *DB) GetAgent(id string) (*Agent, error) {
|
|||||||
|
|
||||||
func (db *DB) ListAgents() ([]Agent, error) {
|
func (db *DB) ListAgents() ([]Agent, error) {
|
||||||
rows, err := db.sql.Query(
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -266,7 +281,7 @@ func (db *DB) ListAgents() ([]Agent, error) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var a Agent
|
var a Agent
|
||||||
var last int64
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
a.LastSeen = time.Unix(last, 0)
|
a.LastSeen = time.Unix(last, 0)
|
||||||
@@ -280,10 +295,16 @@ func (db *DB) TouchAgent(id string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// TouchAgentVersion updates last_seen and the reported version together.
|
// TouchAgentInfo updates last_seen together with the reported version and OS user.
|
||||||
func (db *DB) TouchAgentVersion(id, version string) error {
|
func (db *DB) TouchAgentInfo(id, version, runUser string) error {
|
||||||
_, err := db.sql.Exec(`UPDATE agents SET last_seen = ?, version = ? WHERE id = ?`,
|
_, err := db.sql.Exec(`UPDATE agents SET last_seen = ?, version = ?, run_user = ? WHERE id = ?`,
|
||||||
time.Now().Unix(), version, 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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,6 +329,7 @@ type ForwardRule struct {
|
|||||||
HostPort int
|
HostPort int
|
||||||
ListenAddr string
|
ListenAddr string
|
||||||
Proto string
|
Proto string
|
||||||
|
NoUfw bool // the panel opens no ufw rule for this forward
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) CreateForwardRule(r ForwardRule) (int64, error) {
|
func (db *DB) CreateForwardRule(r ForwardRule) (int64, error) {
|
||||||
@@ -315,9 +337,9 @@ func (db *DB) CreateForwardRule(r ForwardRule) (int64, error) {
|
|||||||
r.Proto = "http"
|
r.Proto = "http"
|
||||||
}
|
}
|
||||||
res, err := db.sql.Exec(
|
res, err := db.sql.Exec(
|
||||||
`INSERT INTO forward_rules(agent_id, client_port, host_port, listen_addr, proto, created_at)
|
`INSERT INTO forward_rules(agent_id, client_port, host_port, listen_addr, proto, no_ufw, created_at)
|
||||||
VALUES(?, ?, ?, ?, ?, ?)`,
|
VALUES(?, ?, ?, ?, ?, ?, ?)`,
|
||||||
r.AgentID, r.ClientPort, r.HostPort, r.ListenAddr, r.Proto, time.Now().Unix())
|
r.AgentID, r.ClientPort, r.HostPort, r.ListenAddr, r.Proto, r.NoUfw, time.Now().Unix())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
@@ -326,7 +348,7 @@ func (db *DB) CreateForwardRule(r ForwardRule) (int64, error) {
|
|||||||
|
|
||||||
func (db *DB) ListForwardRules() ([]ForwardRule, error) {
|
func (db *DB) ListForwardRules() ([]ForwardRule, error) {
|
||||||
rows, err := db.sql.Query(
|
rows, err := db.sql.Query(
|
||||||
`SELECT id, agent_id, client_port, host_port, listen_addr, proto FROM forward_rules ORDER BY id`)
|
`SELECT id, agent_id, client_port, host_port, listen_addr, proto, no_ufw FROM forward_rules ORDER BY id`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -334,7 +356,7 @@ func (db *DB) ListForwardRules() ([]ForwardRule, error) {
|
|||||||
var out []ForwardRule
|
var out []ForwardRule
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var r ForwardRule
|
var r ForwardRule
|
||||||
if err := rows.Scan(&r.ID, &r.AgentID, &r.ClientPort, &r.HostPort, &r.ListenAddr, &r.Proto); err != nil {
|
if err := rows.Scan(&r.ID, &r.AgentID, &r.ClientPort, &r.HostPort, &r.ListenAddr, &r.Proto, &r.NoUfw); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
out = append(out, r)
|
out = append(out, r)
|
||||||
@@ -359,8 +381,8 @@ func (db *DB) ListForwardRulesByAgent(agentID string) ([]ForwardRule, error) {
|
|||||||
func (db *DB) GetForwardRule(id int64) (*ForwardRule, error) {
|
func (db *DB) GetForwardRule(id int64) (*ForwardRule, error) {
|
||||||
r := &ForwardRule{}
|
r := &ForwardRule{}
|
||||||
err := db.sql.QueryRow(
|
err := db.sql.QueryRow(
|
||||||
`SELECT id, agent_id, client_port, host_port, listen_addr, proto FROM forward_rules WHERE id = ?`, id).
|
`SELECT id, agent_id, client_port, host_port, listen_addr, proto, no_ufw FROM forward_rules WHERE id = ?`, id).
|
||||||
Scan(&r.ID, &r.AgentID, &r.ClientPort, &r.HostPort, &r.ListenAddr, &r.Proto)
|
Scan(&r.ID, &r.AgentID, &r.ClientPort, &r.HostPort, &r.ListenAddr, &r.Proto, &r.NoUfw)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -378,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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()) {
|
||||||
@@ -28,7 +28,7 @@ func (s *Server) handleEnroll(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "bad request", http.StatusBadRequest)
|
http.Error(w, "bad request", http.StatusBadRequest)
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -36,23 +36,50 @@ 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 {
|
||||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// The agent sends its version occasionally; record it when present.
|
// The agent sends its version and OS user occasionally; record them when
|
||||||
if v := r.Header.Get("X-Agent-Version"); v != "" && v != a.Version {
|
// present. Agents older than 0.0.2 send no user, so keep what we have.
|
||||||
_ = s.db.TouchAgentVersion(a.ID, v)
|
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 {
|
} else {
|
||||||
_ = s.db.TouchAgent(a.ID)
|
_ = 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) {
|
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)
|
||||||
@@ -66,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)
|
||||||
@@ -81,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)
|
||||||
@@ -100,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)
|
||||||
@@ -121,7 +148,11 @@ func (s *Server) handleTermOut(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if msg.Exit {
|
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)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -113,7 +118,8 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
rows := make([]row, 0, len(agents))
|
rows := make([]row, 0, len(agents))
|
||||||
for _, a := range agents {
|
for _, a := range agents {
|
||||||
rows = append(rows, row{Agent: a, Online: a.Online(), Seen: humanSince(a.LastSeen)})
|
on := s.online(&a)
|
||||||
|
rows = append(rows, row{Agent: a, Online: on, Seen: seenText(on, a.LastSeen)})
|
||||||
}
|
}
|
||||||
s.render(w, "dashboard.html", map[string]any{
|
s.render(w, "dashboard.html", map[string]any{
|
||||||
"Admin": s.currentAdmin(r),
|
"Admin": s.currentAdmin(r),
|
||||||
@@ -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) {
|
func (s *Server) handleTerminalPage(w http.ResponseWriter, r *http.Request) {
|
||||||
id := r.PathValue("id")
|
id := r.PathValue("id")
|
||||||
a, err := s.db.GetAgent(id)
|
a, err := s.db.GetAgent(id)
|
||||||
@@ -141,7 +177,15 @@ func (s *Server) handleAgentDelete(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
id := r.PathValue("id")
|
id := r.PathValue("id")
|
||||||
|
// Its forward rules go with it: left behind they would keep their host
|
||||||
|
// ports bound and busy with no page to remove them from.
|
||||||
|
rules, _ := s.db.ListForwardRulesByAgent(id)
|
||||||
|
for _, rule := range rules {
|
||||||
|
s.removeForward(rule.ID)
|
||||||
|
}
|
||||||
_ = s.db.DeleteAgent(id)
|
_ = s.db.DeleteAgent(id)
|
||||||
|
s.stats.drop(id)
|
||||||
|
s.hub.Forget(id)
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,13 +196,135 @@ func (s *Server) handleAgentEdit(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.render(w, "edit.html", map[string]any{
|
data := map[string]any{
|
||||||
"Agent": a,
|
"Agent": a,
|
||||||
"CSRF": csrfToken(r),
|
"CSRF": csrfToken(r),
|
||||||
"ServerVersion": Version,
|
"ServerVersion": Version,
|
||||||
"Outdated": a.Version != "" && a.Version != Version,
|
"Outdated": a.Version != "" && a.Version != Version,
|
||||||
"Updated": r.URL.Query().Get("updated") == "1",
|
"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.
|
// 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)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !a.Online() {
|
if !s.online(a) {
|
||||||
http.Redirect(w, r, "/agents/"+id+"/edit?updated=0", http.StatusSeeOther)
|
http.Redirect(w, r, "/agents/"+id+"/edit?updated=0", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -223,9 +389,76 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
"UfwRunnable": ufwRunnable,
|
"UfwRunnable": ufwRunnable,
|
||||||
"UfwEnabled": s.db.UfwEnabled(),
|
"UfwEnabled": s.db.UfwEnabled(),
|
||||||
"UfwRules": ufwRules,
|
"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) {
|
func (s *Server) handleSetHost(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)
|
||||||
@@ -282,7 +515,12 @@ func (s *Server) handleUfwToggle(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "bad csrf", http.StatusForbidden)
|
http.Error(w, "bad csrf", http.StatusForbidden)
|
||||||
return
|
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)
|
http.Redirect(w, r, "/settings", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,10 +34,10 @@ func (s *Server) handleFilesPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
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
|
// agent and return its result. The request body is the op's JSON args and is
|
||||||
// forwarded verbatim to the agent.
|
// forwarded verbatim to the agent.
|
||||||
func (s *Server) handleFSOp(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleFSOp(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -53,7 +53,7 @@ func (s *Server) handleFSOp(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !a.Online() {
|
if !s.online(a) {
|
||||||
writeJSON(w, proto.TaskResult{Error: "машина офлайн"})
|
writeJSON(w, proto.TaskResult{Error: "машина офлайн"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -74,7 +74,7 @@ func (s *Server) handleFSOp(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/panel/agents/{id}/fs/download?path=... — stream a file as a download.
|
// GET /api/panel/agents/{id}/fs/download?path=... - stream a file as a download.
|
||||||
func (s *Server) handleFSDownload(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleFSDownload(w http.ResponseWriter, r *http.Request) {
|
||||||
agentID := r.PathValue("id")
|
agentID := r.PathValue("id")
|
||||||
p := r.URL.Query().Get("path")
|
p := r.URL.Query().Get("path")
|
||||||
@@ -83,7 +83,7 @@ func (s *Server) handleFSDownload(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !a.Online() {
|
if !s.online(a) {
|
||||||
http.Error(w, "машина офлайн", http.StatusServiceUnavailable)
|
http.Error(w, "машина офлайн", http.StatusServiceUnavailable)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package server
|
|||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -17,6 +18,7 @@ func (s *Server) handleForwardsPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
rules, _ := s.db.ListForwardRulesByAgent(id)
|
rules, _ := s.db.ListForwardRulesByAgent(id)
|
||||||
errMsg := map[string]string{
|
errMsg := map[string]string{
|
||||||
"busy": "Этот порт хоста уже занят другим правилом.",
|
"busy": "Этот порт хоста уже занят другим правилом.",
|
||||||
|
"tunnel": "Этот порт занят прямым туннелем панели (см. Настройки).",
|
||||||
"port": "Некорректный номер порта.",
|
"port": "Некорректный номер порта.",
|
||||||
"listen": "Не удалось занять порт на хосте (возможно, уже используется).",
|
"listen": "Не удалось занять порт на хосте (возможно, уже используется).",
|
||||||
"db": "Ошибка базы данных.",
|
"db": "Ошибка базы данных.",
|
||||||
@@ -26,9 +28,27 @@ func (s *Server) handleForwardsPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
"Rules": rules,
|
"Rules": rules,
|
||||||
"CSRF": csrfToken(r),
|
"CSRF": csrfToken(r),
|
||||||
"Error": errMsg,
|
"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
|
// POST /agents/{id}/forwards/add
|
||||||
func (s *Server) handleForwardAdd(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleForwardAdd(w http.ResponseWriter, r *http.Request) {
|
||||||
if !checkCSRF(r) {
|
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)
|
http.Redirect(w, r, "/agents/"+id+"/forwards?err=port", http.StatusSeeOther)
|
||||||
return
|
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) {
|
if s.db.HostPortUsed(hostPort) {
|
||||||
http.Redirect(w, r, "/agents/"+id+"/forwards?err=busy", http.StatusSeeOther)
|
http.Redirect(w, r, "/agents/"+id+"/forwards?err=busy", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rule := ForwardRule{AgentID: id, ClientPort: clientPort, HostPort: hostPort, ListenAddr: listenAddr, Proto: protoKind}
|
rule := ForwardRule{AgentID: id, ClientPort: clientPort, HostPort: hostPort, ListenAddr: listenAddr, Proto: protoKind,
|
||||||
|
NoUfw: r.FormValue("no_ufw") != ""}
|
||||||
ruleID, err := s.db.CreateForwardRule(rule)
|
ruleID, err := s.db.CreateForwardRule(rule)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Redirect(w, r, "/agents/"+id+"/forwards?err=db", http.StatusSeeOther)
|
http.Redirect(w, r, "/agents/"+id+"/forwards?err=db", http.StatusSeeOther)
|
||||||
@@ -89,13 +114,19 @@ func (s *Server) handleForwardDelete(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
rid, _ := strconv.ParseInt(r.PathValue("rid"), 10, 64)
|
rid, _ := strconv.ParseInt(r.PathValue("rid"), 10, 64)
|
||||||
rule, _ := s.db.GetForwardRule(rid)
|
rule, _ := s.db.GetForwardRule(rid)
|
||||||
s.fwd.close(rid)
|
s.removeForward(rid)
|
||||||
_ = s.db.DeleteForwardRule(rid)
|
|
||||||
// Remove the ufw rule bound to this forward rule (shared 8446 stays).
|
|
||||||
s.ufw.revokeForRule(rid)
|
|
||||||
dest := "/"
|
dest := "/"
|
||||||
if rule != nil {
|
if rule != nil {
|
||||||
dest = "/agents/" + rule.AgentID + "/forwards"
|
dest = "/agents/" + rule.AgentID + "/forwards"
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, dest, http.StatusSeeOther)
|
http.Redirect(w, r, dest, http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// removeForward closes a forward rule's listener and deletes the rule, along
|
||||||
|
// with the ufw rule bound to it (the shared tunnel port stays while another
|
||||||
|
// direct forward needs it).
|
||||||
|
func (s *Server) removeForward(rid int64) {
|
||||||
|
s.fwd.close(rid)
|
||||||
|
_ = s.db.DeleteForwardRule(rid)
|
||||||
|
s.ufw.revokeForRule(rid)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -21,14 +21,14 @@ func (s *Server) handlePanelOpen(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sess := s.hub.CreateSession(agentID)
|
sess := s.hub.CreateSession(agentID, agent.LoginUser)
|
||||||
if admin := s.currentAdmin(r); admin != nil {
|
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})
|
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)
|
||||||
|
|||||||
+74
-7
@@ -22,6 +22,12 @@ type Hub struct {
|
|||||||
|
|
||||||
sessions map[string]*Session
|
sessions map[string]*Session
|
||||||
tasks map[string]*taskEntry
|
tasks map[string]*taskEntry
|
||||||
|
|
||||||
|
// Presence: control polls held open right now per agent, and when each
|
||||||
|
// agent's last one ended. started is when this hub came up.
|
||||||
|
polls map[string]int
|
||||||
|
lastPoll map[string]time.Time
|
||||||
|
started time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// taskEntry is a pending one-shot task awaiting the agent's result.
|
// taskEntry is a pending one-shot task awaiting the agent's result.
|
||||||
@@ -31,7 +37,7 @@ type taskEntry struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Session is one live byte stream between the panel and an agent (a PTY terminal
|
// Session is one live byte stream between the panel and an agent (a PTY terminal
|
||||||
// or a raw TCP forward — the hub treats both identically).
|
// or a raw TCP forward - the hub treats both identically).
|
||||||
type Session struct {
|
type Session struct {
|
||||||
ID string
|
ID string
|
||||||
AgentID string
|
AgentID string
|
||||||
@@ -52,6 +58,9 @@ func NewHub() *Hub {
|
|||||||
ctrlWaiters: map[string]chan struct{}{},
|
ctrlWaiters: map[string]chan struct{}{},
|
||||||
sessions: map[string]*Session{},
|
sessions: map[string]*Session{},
|
||||||
tasks: map[string]*taskEntry{},
|
tasks: map[string]*taskEntry{},
|
||||||
|
polls: map[string]int{},
|
||||||
|
lastPoll: map[string]time.Time{},
|
||||||
|
started: time.Now(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,10 +91,11 @@ func (h *Hub) newSession(agentID string) *Session {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateSession registers a terminal session and queues it for the agent.
|
// CreateSession registers a terminal session and queues it for the agent. user
|
||||||
func (h *Hub) CreateSession(agentID string) *Session {
|
// 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)
|
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
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,9 +106,56 @@ func (h *Hub) CreateForward(agentID string, port int) *Session {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
// WaitControl blocks until a job is queued for agentID or timeout elapses.
|
// pollGrace is how long an agent stays online between two control polls: it
|
||||||
func (h *Hub) WaitControl(agentID string, timeout time.Duration) proto.ControlMessage {
|
// re-polls at once, so this only has to cover a slow round trip or one retry.
|
||||||
deadline := time.After(timeout)
|
const pollGrace = 10 * time.Second
|
||||||
|
|
||||||
|
// Online reports whether the agent is connected: it holds a control poll open
|
||||||
|
// or has just finished one. ok is false while that cannot be told yet (right
|
||||||
|
// after a start, before agents had time to come back).
|
||||||
|
func (h *Hub) Online(agentID string) (online, ok bool) {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
if h.polls[agentID] > 0 {
|
||||||
|
return true, true
|
||||||
|
}
|
||||||
|
if t, seen := h.lastPoll[agentID]; seen {
|
||||||
|
return time.Since(t) < pollGrace, true
|
||||||
|
}
|
||||||
|
return false, time.Since(h.started) > pollHold+pollGrace
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forget drops what the hub keeps about a deleted agent.
|
||||||
|
func (h *Hub) Forget(agentID string) {
|
||||||
|
h.mu.Lock()
|
||||||
|
delete(h.jobs, agentID)
|
||||||
|
delete(h.ctrlWaiters, agentID)
|
||||||
|
delete(h.lastPoll, agentID)
|
||||||
|
h.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// WaitControl blocks until a job is queued for agentID, the timeout elapses
|
||||||
|
// or the agent hangs up (done). The agent counts as online meanwhile.
|
||||||
|
func (h *Hub) WaitControl(done <-chan struct{}, agentID string, timeout time.Duration) proto.ControlMessage {
|
||||||
|
h.mu.Lock()
|
||||||
|
h.polls[agentID]++
|
||||||
|
h.mu.Unlock()
|
||||||
|
defer func() {
|
||||||
|
h.mu.Lock()
|
||||||
|
if h.polls[agentID]--; h.polls[agentID] <= 0 {
|
||||||
|
delete(h.polls, agentID)
|
||||||
|
}
|
||||||
|
// A poll cut off by the agent is not a sign of life.
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
default:
|
||||||
|
h.lastPoll[agentID] = time.Now()
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
}()
|
||||||
|
t := time.NewTimer(timeout)
|
||||||
|
defer t.Stop()
|
||||||
|
deadline := t.C
|
||||||
for {
|
for {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
if q := h.jobs[agentID]; len(q) > 0 {
|
if q := h.jobs[agentID]; len(q) > 0 {
|
||||||
@@ -118,6 +175,9 @@ func (h *Hub) WaitControl(agentID string, timeout time.Duration) proto.ControlMe
|
|||||||
case <-w:
|
case <-w:
|
||||||
case <-deadline:
|
case <-deadline:
|
||||||
return proto.ControlMessage{Type: proto.CtrlIdle}
|
return proto.ControlMessage{Type: proto.CtrlIdle}
|
||||||
|
case <-done:
|
||||||
|
// The job, if one comes, stays queued for the next poll.
|
||||||
|
return proto.ControlMessage{Type: proto.CtrlIdle}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -245,8 +305,15 @@ func (s *Session) PollOut(timeout time.Duration) ([]proto.OutMessage, bool) {
|
|||||||
case <-time.After(timeout):
|
case <-time.After(timeout):
|
||||||
return nil, true
|
return nil, true
|
||||||
case <-s.closed:
|
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
|
return nil, false
|
||||||
}
|
}
|
||||||
|
}
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case m := <-s.out:
|
case m := <-s.out:
|
||||||
|
|||||||
+101
-15
@@ -9,6 +9,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
texttemplate "text/template"
|
texttemplate "text/template"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -24,7 +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.
|
// Empty disables direct forwarding. The port can be overridden from the
|
||||||
|
// settings page.
|
||||||
TunnelAddr string
|
TunnelAddr string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,12 +36,24 @@ type Server struct {
|
|||||||
db *DB
|
db *DB
|
||||||
hub *Hub
|
hub *Hub
|
||||||
fwd *forwardManager
|
fwd *forwardManager
|
||||||
|
tun *tunnelHub
|
||||||
|
udp *udpRelay
|
||||||
ufw *ufwManager
|
ufw *ufwManager
|
||||||
tmpl *template.Template
|
tmpl *template.Template
|
||||||
limiter *loginLimiter
|
limiter *loginLimiter
|
||||||
distDir string
|
distDir string
|
||||||
secureCookies bool
|
secureCookies bool
|
||||||
installTmpl *texttemplate.Template
|
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.
|
// New builds the server, parsing embedded templates.
|
||||||
@@ -65,26 +80,81 @@ func New(opts Options) (*Server, error) {
|
|||||||
db: opts.DB,
|
db: opts.DB,
|
||||||
hub: hub,
|
hub: hub,
|
||||||
fwd: newForwardManager(hub, tun, udp),
|
fwd: newForwardManager(hub, tun, udp),
|
||||||
ufw: newUfwManager(opts.DB, portFromAddr(opts.TunnelAddr)),
|
tun: tun,
|
||||||
|
udp: udp,
|
||||||
tmpl: tmpl,
|
tmpl: tmpl,
|
||||||
limiter: newLoginLimiter(),
|
limiter: newLoginLimiter(),
|
||||||
distDir: opts.DistDir,
|
distDir: opts.DistDir,
|
||||||
secureCookies: opts.SecureCookies,
|
secureCookies: opts.SecureCookies,
|
||||||
installTmpl: installTmpl,
|
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 opts.TunnelAddr != "" {
|
||||||
if err := tun.listen(opts.TunnelAddr); err != nil {
|
s.tunnelBind, s.tunnelDefault = splitAddr(opts.TunnelAddr)
|
||||||
log.Printf("tunnel tcp listen %s: %v", opts.TunnelAddr, err)
|
|
||||||
}
|
}
|
||||||
if err := udp.listen(opts.TunnelAddr); err != nil {
|
if s.tunnelDefault > 0 {
|
||||||
log.Printf("tunnel udp listen %s: %v", opts.TunnelAddr, err)
|
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()
|
s.restoreForwards()
|
||||||
|
go s.ufw.sync()
|
||||||
return s, nil
|
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.
|
// restoreForwards re-opens the host listeners for all saved forward rules.
|
||||||
func (s *Server) restoreForwards() {
|
func (s *Server) restoreForwards() {
|
||||||
rules, err := s.db.ListForwardRules()
|
rules, err := s.db.ListForwardRules()
|
||||||
@@ -93,6 +163,12 @@ func (s *Server) restoreForwards() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, r := range rules {
|
for _, r := range rules {
|
||||||
|
// Rules of an agent deleted by an older version: drop them.
|
||||||
|
if a, err := s.db.GetAgent(r.AgentID); err == nil && a == nil {
|
||||||
|
log.Printf("forward rule %d: agent %s is gone, removing", r.ID, r.AgentID)
|
||||||
|
s.removeForward(r.ID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
if err := s.fwd.open(r); err != nil {
|
if err := s.fwd.open(r); err != nil {
|
||||||
log.Printf("restore forward rule %d (%s:%d): %v", r.ID, r.ListenAddr, r.HostPort, err)
|
log.Printf("restore forward rule %d (%s:%d): %v", r.ID, r.ListenAddr, r.HostPort, err)
|
||||||
}
|
}
|
||||||
@@ -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}/terminal", s.requireAdmin(s.handleTerminalPage))
|
||||||
mux.Handle("GET /agents/{id}/files", s.requireAdmin(s.handleFilesPage))
|
mux.Handle("GET /agents/{id}/files", s.requireAdmin(s.handleFilesPage))
|
||||||
mux.Handle("GET /agents/{id}/forwards", s.requireAdmin(s.handleForwardsPage))
|
mux.Handle("GET /agents/{id}/forwards", s.requireAdmin(s.handleForwardsPage))
|
||||||
|
mux.Handle("GET /agents/{id}/status", s.requireAdmin(s.handleStatusPage))
|
||||||
mux.Handle("GET /agents/{id}/edit", s.requireAdmin(s.handleAgentEdit))
|
mux.Handle("GET /agents/{id}/edit", s.requireAdmin(s.handleAgentEdit))
|
||||||
mux.Handle("POST /agents/{id}/rename", s.requireAdmin(s.handleAgentRename))
|
mux.Handle("POST /agents/{id}/rename", s.requireAdmin(s.handleAgentRename))
|
||||||
mux.Handle("POST /agents/{id}/update", s.requireAdmin(s.handleAgentUpdate))
|
mux.Handle("POST /agents/{id}/update", s.requireAdmin(s.handleAgentUpdate))
|
||||||
|
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("POST /agents/{id}/delete", s.requireAdmin(s.handleAgentDelete))
|
||||||
mux.Handle("GET /settings", s.requireAdmin(s.handleSettings))
|
mux.Handle("GET /settings", s.requireAdmin(s.handleSettings))
|
||||||
mux.Handle("POST /settings/host", s.requireAdmin(s.handleSetHost))
|
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/secret/rotate", s.requireAdmin(s.handleRotateSecret))
|
||||||
mux.Handle("POST /settings/admins/add", s.requireAdmin(s.handleAddAdmin))
|
mux.Handle("POST /settings/admins/add", s.requireAdmin(s.handleAddAdmin))
|
||||||
mux.Handle("POST /settings/admins/delete", s.requireAdmin(s.handleDeleteAdmin))
|
mux.Handle("POST /settings/admins/delete", s.requireAdmin(s.handleDeleteAdmin))
|
||||||
@@ -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("GET /api/panel/agents/{id}/fs/download", s.requireAdmin(s.handleFSDownload))
|
||||||
mux.Handle("POST /api/panel/agents/{id}/fs/{op}", s.requireAdmin(s.handleFSOp))
|
mux.Handle("POST /api/panel/agents/{id}/fs/{op}", s.requireAdmin(s.handleFSOp))
|
||||||
|
|
||||||
|
// Machine status (admin).
|
||||||
|
mux.Handle("GET /api/panel/agents/presence", s.requireAdmin(s.handlePresence))
|
||||||
|
mux.Handle("GET /api/panel/agents/{id}/stat", s.requireAdmin(s.handleAgentStat))
|
||||||
|
mux.Handle("POST /api/panel/agents/{id}/speed", s.requireAdmin(s.handleAgentSpeed))
|
||||||
|
|
||||||
// Panel port forwarding (admin-only).
|
// Panel port forwarding (admin-only).
|
||||||
mux.Handle("POST /agents/{id}/forwards/add", s.requireAdmin(s.handleForwardAdd))
|
mux.Handle("POST /agents/{id}/forwards/add", s.requireAdmin(s.handleForwardAdd))
|
||||||
mux.Handle("POST /forwards/{rid}/delete", s.requireAdmin(s.handleForwardDelete))
|
mux.Handle("POST /forwards/{rid}/delete", s.requireAdmin(s.handleForwardDelete))
|
||||||
@@ -155,10 +240,13 @@ func (s *Server) Handler() http.Handler {
|
|||||||
// Agent API (token/secret auth inside handlers).
|
// Agent API (token/secret auth inside handlers).
|
||||||
mux.HandleFunc("POST /api/agent/enroll", s.handleEnroll)
|
mux.HandleFunc("POST /api/agent/enroll", s.handleEnroll)
|
||||||
mux.HandleFunc("GET /api/agent/poll", s.handlePoll)
|
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("GET /api/agent/term/{sid}/in", s.handleTermIn)
|
||||||
mux.HandleFunc("POST /api/agent/term/{sid}/out", s.handleTermOut)
|
mux.HandleFunc("POST /api/agent/term/{sid}/out", s.handleTermOut)
|
||||||
mux.HandleFunc("GET /api/agent/task/{id}", s.handleTaskFetch)
|
mux.HandleFunc("GET /api/agent/task/{id}", s.handleTaskFetch)
|
||||||
mux.HandleFunc("POST /api/agent/task/{id}/result", s.handleTaskResult)
|
mux.HandleFunc("POST /api/agent/task/{id}/result", s.handleTaskResult)
|
||||||
|
mux.HandleFunc("GET /api/agent/speed/down", s.handleSpeedDown)
|
||||||
|
mux.HandleFunc("POST /api/agent/speed/up", s.handleSpeedUp)
|
||||||
|
|
||||||
return s.logRequests(mux)
|
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.
|
// splitAddr splits a "host:port" or ":port" listen address. The port is 0 when
|
||||||
func portFromAddr(addr string) int {
|
// the address has none.
|
||||||
if addr == "" {
|
func splitAddr(addr string) (host string, port int) {
|
||||||
return 0
|
h, p, err := net.SplitHostPort(addr)
|
||||||
}
|
|
||||||
_, p, err := net.SplitHostPort(addr)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0
|
return "", 0
|
||||||
}
|
}
|
||||||
n, _ := strconv.Atoi(p)
|
n, _ := strconv.Atoi(p)
|
||||||
return n
|
return h, n
|
||||||
}
|
}
|
||||||
|
|
||||||
func clientIP(r *http.Request) string {
|
func clientIP(r *http.Request) string {
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ func readLine(r io.Reader, max int) (string, error) {
|
|||||||
type tunnelHub struct {
|
type tunnelHub struct {
|
||||||
db *DB
|
db *DB
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
|
ln net.Listener
|
||||||
agents map[string]*agentTunnel
|
agents map[string]*agentTunnel
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,12 +58,30 @@ type agentTunnel struct {
|
|||||||
streams map[uint32]net.Conn
|
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 {
|
func (h *tunnelHub) listen(addr string) error {
|
||||||
ln, err := net.Listen("tcp", addr)
|
ln, err := net.Listen("tcp", addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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)
|
log.Printf("tunnel: listening on %s (direct TCP forwarding)", addr)
|
||||||
go func() {
|
go func() {
|
||||||
for {
|
for {
|
||||||
|
|||||||
+21
-9
@@ -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
|
||||||
@@ -23,6 +23,7 @@ type udpRelay struct {
|
|||||||
db *DB
|
db *DB
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
ctrl *net.UDPConn
|
ctrl *net.UDPConn
|
||||||
|
gcOnce sync.Once
|
||||||
|
|
||||||
agentAddr map[string]*net.UDPAddr // agentID -> last registered addr
|
agentAddr map[string]*net.UDPAddr // agentID -> last registered addr
|
||||||
sessions map[uint32]*udpSession
|
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 {
|
func (u *udpRelay) listen(addr string) error {
|
||||||
ua, err := net.ResolveUDPAddr("udp", addr)
|
ua, err := net.ResolveUDPAddr("udp", addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -58,17 +60,26 @@ func (u *udpRelay) listen(addr string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
u.mu.Lock()
|
||||||
|
old := u.ctrl
|
||||||
u.ctrl = c
|
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)
|
log.Printf("tunnel: listening on %s/udp (direct UDP forwarding)", addr)
|
||||||
go u.ctrlLoop()
|
go u.ctrlLoop(c)
|
||||||
go u.gc()
|
u.gcOnce.Do(func() { go u.gc() })
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *udpRelay) ctrlLoop() {
|
func (u *udpRelay) ctrlLoop(ctrl *net.UDPConn) {
|
||||||
buf := make([]byte, 65535)
|
buf := make([]byte, 65535)
|
||||||
for {
|
for {
|
||||||
n, src, err := u.ctrl.ReadFromUDP(buf)
|
n, src, err := ctrl.ReadFromUDP(buf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -171,6 +182,7 @@ func (u *udpRelay) rulePortLoop(rule ForwardRule, c *net.UDPConn) {
|
|||||||
}
|
}
|
||||||
u.mu.Lock()
|
u.mu.Lock()
|
||||||
aAddr := u.agentAddr[rule.AgentID]
|
aAddr := u.agentAddr[rule.AgentID]
|
||||||
|
ctrl := u.ctrl
|
||||||
// Find existing session for this (rule, user).
|
// Find existing session for this (rule, user).
|
||||||
var sess *udpSession
|
var sess *udpSession
|
||||||
for _, s := range u.sessions {
|
for _, s := range u.sessions {
|
||||||
@@ -190,7 +202,7 @@ func (u *udpRelay) rulePortLoop(rule ForwardRule, c *net.UDPConn) {
|
|||||||
sess.last = time.Now()
|
sess.last = time.Now()
|
||||||
u.mu.Unlock()
|
u.mu.Unlock()
|
||||||
|
|
||||||
if aAddr == nil {
|
if aAddr == nil || ctrl == nil {
|
||||||
continue // agent has not registered its UDP endpoint yet
|
continue // agent has not registered its UDP endpoint yet
|
||||||
}
|
}
|
||||||
// 'D' | sid(4) | clientPort(2) | payload
|
// '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.PutUint32(out[1:5], sess.id)
|
||||||
binary.BigEndian.PutUint16(out[5:7], uint16(rule.ClientPort))
|
binary.BigEndian.PutUint16(out[5:7], uint16(rule.ClientPort))
|
||||||
copy(out[7:], buf[:n])
|
copy(out[7:], buf[:n])
|
||||||
_, _ = u.ctrl.WriteToUDP(out, aAddr)
|
_, _ = ctrl.WriteToUDP(out, aAddr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+128
-30
@@ -3,8 +3,11 @@ package server
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ufwManager integrates with the host firewall (ufw) when it is installed and
|
// 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.
|
// (e.g. running as root). When it cannot, integration is simply inactive.
|
||||||
type ufwManager struct {
|
type ufwManager struct {
|
||||||
db *DB
|
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}
|
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.
|
// status reports whether ufw is installed, active, and runnable by this process.
|
||||||
func (u *ufwManager) status() (installed, active, runnable bool) {
|
func (u *ufwManager) status() (installed, active, runnable bool) {
|
||||||
path, err := exec.LookPath("ufw")
|
if _, err := exec.LookPath("ufw"); err != nil {
|
||||||
if err != nil {
|
|
||||||
return false, false, false
|
return false, false, false
|
||||||
}
|
}
|
||||||
installed = true
|
installed = true
|
||||||
out, err := exec.Command(path, "status").CombinedOutput()
|
out, err := ufwRun("status")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Typically a permission error (not root) -> not runnable.
|
// Typically a permission error (not root) -> not runnable.
|
||||||
return true, false, false
|
return true, false, false
|
||||||
}
|
}
|
||||||
runnable = true
|
runnable = true
|
||||||
active = strings.Contains(string(out), "Status: active")
|
active = strings.Contains(out, "Status: active")
|
||||||
return installed, active, runnable
|
return installed, active, runnable
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// enabled reports whether the integration is switched on and ufw is there to
|
||||||
|
// drive. Unlike available it runs nothing, so pages can ask it freely.
|
||||||
|
func (u *ufwManager) enabled() bool {
|
||||||
|
if !u.db.UfwEnabled() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, err := exec.LookPath("ufw")
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
// available reports whether we can and should drive ufw now.
|
// available reports whether we can and should drive ufw now.
|
||||||
func (u *ufwManager) available() bool {
|
func (u *ufwManager) available() bool {
|
||||||
if !u.db.UfwEnabled() {
|
if !u.db.UfwEnabled() {
|
||||||
@@ -48,18 +73,36 @@ func (u *ufwManager) available() bool {
|
|||||||
return installed && active && runnable
|
return installed && active && runnable
|
||||||
}
|
}
|
||||||
|
|
||||||
// allow opens port/proto in ufw (if integration is available) and records it,
|
func (u *ufwManager) setErr(msg string) {
|
||||||
// bound to the given forward rule id (0 = shared/manual).
|
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) {
|
func (u *ufwManager) allow(port int, proto string, ruleID int64) {
|
||||||
if !u.available() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if u.db.HasUfwRule(port, proto) {
|
if u.db.HasUfwRule(port, proto) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
spec := fmt.Sprintf("%d/%s", port, proto)
|
spec := fmt.Sprintf("%d/%s", port, proto)
|
||||||
if out, err := exec.Command("ufw", "allow", spec).CombinedOutput(); err != nil {
|
out, err := ufwRun("allow", spec)
|
||||||
log.Printf("ufw allow %s: %v: %s", spec, err, strings.TrimSpace(string(out)))
|
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
|
return
|
||||||
}
|
}
|
||||||
_ = u.db.AddUfwRule(port, proto, ruleID)
|
_ = 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.
|
// revokeRule deletes one tracked ufw rule from ufw and the tracking table.
|
||||||
func (u *ufwManager) revokeRule(r UfwRule) {
|
func (u *ufwManager) revokeRule(r UfwRule) {
|
||||||
spec := fmt.Sprintf("%d/%s", r.Port, r.Proto)
|
spec := fmt.Sprintf("%d/%s", r.Port, r.Proto)
|
||||||
if out, err := exec.Command("ufw", "delete", "allow", spec).CombinedOutput(); err != nil {
|
if out, err := ufwRun("delete", "allow", spec); err != nil {
|
||||||
log.Printf("ufw delete allow %s: %v: %s", spec, err, strings.TrimSpace(string(out)))
|
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 {
|
} else {
|
||||||
|
u.setErr("")
|
||||||
log.Printf("ufw: revoked %s", spec)
|
log.Printf("ufw: revoked %s", spec)
|
||||||
}
|
}
|
||||||
_ = u.db.DeleteUfwRule(r.ID)
|
_ = u.db.DeleteUfwRule(r.ID)
|
||||||
@@ -86,8 +131,8 @@ func (u *ufwManager) revoke(id int64) {
|
|||||||
u.revokeRule(*r)
|
u.revokeRule(*r)
|
||||||
}
|
}
|
||||||
|
|
||||||
// revokeForRule removes the ufw rules bound to a specific forward rule. The
|
// revokeForRule removes the ufw rules bound to a specific forward rule, and
|
||||||
// shared tunnel port (rule_id 0) is left alone, since other forwards use it.
|
// the shared tunnel port once no forward needs it any more.
|
||||||
func (u *ufwManager) revokeForRule(ruleID int64) {
|
func (u *ufwManager) revokeForRule(ruleID int64) {
|
||||||
rules, err := u.db.ListUfwRulesByRule(ruleID)
|
rules, err := u.db.ListUfwRulesByRule(ruleID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -96,24 +141,77 @@ func (u *ufwManager) revokeForRule(ruleID int64) {
|
|||||||
for _, r := range rules {
|
for _, r := range rules {
|
||||||
u.revokeRule(r)
|
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
|
// 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
|
// to the rule so it is revoked with it) plus the shared tunnel port for direct
|
||||||
// direct tcp/udp modes (bound to 0 — shared across rules).
|
// tcp/udp modes (bound to 0 - shared across rules). A rule made "without ufw"
|
||||||
|
// opens nothing, so nothing is revoked with it either.
|
||||||
func (u *ufwManager) allowForward(rule ForwardRule) {
|
func (u *ufwManager) allowForward(rule ForwardRule) {
|
||||||
switch rule.Proto {
|
if !u.available() {
|
||||||
case "udp":
|
return
|
||||||
u.allow(rule.HostPort, "udp", rule.ID)
|
|
||||||
if u.tunnelPort > 0 {
|
|
||||||
u.allow(u.tunnelPort, "udp", 0)
|
|
||||||
}
|
}
|
||||||
case "tcp":
|
u.allowForwardPorts(rule)
|
||||||
u.allow(rule.HostPort, "tcp", rule.ID)
|
}
|
||||||
if u.tunnelPort > 0 {
|
|
||||||
u.allow(u.tunnelPort, "tcp", 0)
|
func (u *ufwManager) allowForwardPorts(rule ForwardRule) {
|
||||||
|
if rule.NoUfw {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
default: // http long-poll listener is TCP
|
// "http" (long-poll) and "tcp" forwards both listen on TCP on the host.
|
||||||
u.allow(rule.HostPort, "tcp", rule.ID)
|
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()
|
||||||
|
}
|
||||||
@@ -34,10 +34,24 @@ header.topbar nav a:hover { color: var(--text); }
|
|||||||
h1 { font-size: 22px; margin: 0 0 18px; }
|
h1 { font-size: 22px; margin: 0 0 18px; }
|
||||||
h2 { font-size: 17px; margin: 0 0 14px; }
|
h2 { font-size: 17px; margin: 0 0 14px; }
|
||||||
label { display: block; margin: 12px 0 4px; color: var(--muted); font-size: 13px; }
|
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;
|
width: 100%; padding: 10px 12px; border-radius: 8px;
|
||||||
border: 1px solid var(--border); background: #0c0f13; color: var(--text); font-size: 14px;
|
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 {
|
button, .btn {
|
||||||
display: inline-block; cursor: pointer; border: 0; border-radius: 8px;
|
display: inline-block; cursor: pointer; border: 0; border-radius: 8px;
|
||||||
padding: 10px 16px; font-size: 14px; font-weight: 500;
|
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.small { padding: 6px 10px; font-size: 13px; }
|
||||||
.btn.icon { width: 30px; height: 30px; padding: 0; display: inline-flex;
|
.btn.icon { width: 30px; height: 30px; padding: 0; display: inline-flex;
|
||||||
align-items: center; justify-content: center; font-size: 14px; line-height: 1; text-decoration: none; }
|
align-items: center; justify-content: center; font-size: 14px; line-height: 1; text-decoration: none; }
|
||||||
|
/* Name of an icon button, shown at once on hover or keyboard focus. */
|
||||||
|
[data-tip] { position: relative; }
|
||||||
|
[data-tip]::after {
|
||||||
|
content: attr(data-tip); position: absolute; bottom: calc(100% + 6px); left: 50%;
|
||||||
|
transform: translateX(-50%); padding: 4px 8px; border-radius: 6px; white-space: nowrap;
|
||||||
|
background: #0c0f13; border: 1px solid var(--border); color: var(--text);
|
||||||
|
font-size: 12px; font-weight: 400; line-height: 1.4;
|
||||||
|
opacity: 0; visibility: hidden; pointer-events: none; z-index: 10;
|
||||||
|
}
|
||||||
|
[data-tip]:hover::after, [data-tip]:focus-visible::after { opacity: 1; visibility: visible; }
|
||||||
.err { background: #3a1d1d; border: 1px solid #6b2b2b; color: #ffb3b3; padding: 10px 12px; border-radius: 8px; margin-bottom: 14px; font-size: 14px; }
|
.err { background: #3a1d1d; border: 1px solid #6b2b2b; color: #ffb3b3; padding: 10px 12px; border-radius: 8px; margin-bottom: 14px; font-size: 14px; }
|
||||||
table { width: 100%; border-collapse: collapse; }
|
table { width: 100%; border-collapse: collapse; }
|
||||||
th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--border); font-size: 14px; }
|
th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--border); font-size: 14px; }
|
||||||
th { color: var(--muted); font-weight: 500; }
|
th { color: var(--muted); font-weight: 500; }
|
||||||
|
.nowrap { white-space: nowrap; }
|
||||||
|
/* Row buttons stay on one line; the column is only as wide as they need. */
|
||||||
|
td.actions { width: 1%; white-space: nowrap; }
|
||||||
|
td.actions .row { flex-wrap: nowrap; justify-content: flex-end; }
|
||||||
.dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; margin-right: 6px; }
|
.dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; margin-right: 6px; }
|
||||||
.dot.on { background: var(--ok); }
|
.dot.on { background: var(--ok); }
|
||||||
.dot.off { background: #555d6a; }
|
.dot.off { background: #555d6a; }
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -22,27 +22,36 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
{{if .Agents}}
|
{{if .Agents}}
|
||||||
<table>
|
<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>
|
<tbody>
|
||||||
{{range .Agents}}
|
{{range .Agents}}
|
||||||
<tr>
|
<tr data-agent="{{.ID}}">
|
||||||
<td><span class="dot {{if .Online}}on{{else}}off{{end}}"></span>{{if .Online}}online{{else}}offline{{end}}</td>
|
<td class="nowrap"><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>{{.Name}}</td>
|
||||||
<td class="muted">{{.Hostname}}</td>
|
<td class="muted">{{.Hostname}}</td>
|
||||||
<td class="muted">{{.OS}}/{{.Arch}}</td>
|
<td class="muted nowrap">{{.OS}}/{{.Arch}}</td>
|
||||||
<td class="muted">{{.Seen}}</td>
|
<td class="muted nowrap seen">{{.Seen}}</td>
|
||||||
<td>
|
<td class="actions">
|
||||||
<div class="row" style="gap:6px">
|
<div class="row" style="gap:6px">
|
||||||
<a class="btn small" href="/agents/{{.ID}}/terminal">Терминал</a>
|
<a class="btn small icon" href="/agents/{{.ID}}/terminal" data-tip="Терминал" aria-label="Терминал">
|
||||||
<a class="btn ghost small" href="/agents/{{.ID}}/files">Файлы</a>
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>
|
||||||
<a class="btn ghost small" href="/agents/{{.ID}}/forwards">Порты</a>
|
</a>
|
||||||
<a class="btn ghost small icon" href="/agents/{{.ID}}/edit" title="Изменить">
|
<a class="btn ghost small icon" href="/agents/{{.ID}}/files" data-tip="Файлы" aria-label="Файлы">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
|
||||||
|
</a>
|
||||||
|
<a class="btn ghost small icon" href="/agents/{{.ID}}/forwards" data-tip="Порты" aria-label="Порты">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>
|
||||||
|
</a>
|
||||||
|
<a class="btn ghost small icon" href="/agents/{{.ID}}/status" data-tip="Статус" aria-label="Статус">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
|
||||||
|
</a>
|
||||||
|
<a class="btn ghost small icon" href="/agents/{{.ID}}/edit" data-tip="Изменить" aria-label="Изменить">
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z"/></svg>
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z"/></svg>
|
||||||
</a>
|
</a>
|
||||||
<form method="post" action="/agents/{{.ID}}/delete" style="display:inline"
|
<form method="post" action="/agents/{{.ID}}/delete" style="display:inline"
|
||||||
onsubmit="return confirm('Удалить машину {{.Name}}? Сервис на ней продолжит работать, но перестанет быть виден. Чтобы снова подключиться — переустановите агента.')">
|
onsubmit="return confirm('Удалить машину {{.Name}}? Сервис на ней продолжит работать, но перестанет быть виден. Чтобы снова подключиться - переустановите агента.')">
|
||||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||||
<button class="btn danger small icon" type="submit" title="Удалить">
|
<button class="btn danger small icon" type="submit" data-tip="Удалить" aria-label="Удалить">
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -54,9 +63,42 @@
|
|||||||
</table>
|
</table>
|
||||||
{{else}}
|
{{else}}
|
||||||
<p class="muted">Пока нет подключённых машин. Добавьте первую в разделе
|
<p class="muted">Пока нет подключённых машин. Добавьте первую в разделе
|
||||||
<a href="/settings">Настройки</a> — там одна команда для установки.</p>
|
<a href="/settings">Настройки</a> - там одна команда для установки.</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<script>
|
||||||
|
// Keep the status column live; nothing is asked while the tab is hidden.
|
||||||
|
(function () {
|
||||||
|
var rows = {};
|
||||||
|
document.querySelectorAll('tr[data-agent]').forEach(function (tr) { rows[tr.dataset.agent] = tr; });
|
||||||
|
var timer = null, busy = false;
|
||||||
|
function schedule() {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (!document.hidden) timer = setTimeout(load, 5000);
|
||||||
|
}
|
||||||
|
function load() {
|
||||||
|
if (busy || document.hidden) return;
|
||||||
|
busy = true;
|
||||||
|
fetch('/api/panel/agents/presence', {credentials: 'same-origin'})
|
||||||
|
.then(function (r) { return r.ok ? r.json() : []; })
|
||||||
|
.then(function (list) {
|
||||||
|
list.forEach(function (a) {
|
||||||
|
var tr = rows[a.id];
|
||||||
|
if (!tr) return;
|
||||||
|
tr.querySelector('.dot').className = 'dot ' + (a.online ? 'on' : 'off');
|
||||||
|
tr.querySelector('.state').textContent = a.online ? 'online' : 'offline';
|
||||||
|
tr.querySelector('.seen').textContent = a.seen;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(function () {})
|
||||||
|
.then(function () { busy = false; schedule(); });
|
||||||
|
}
|
||||||
|
document.addEventListener('visibilitychange', function () {
|
||||||
|
if (document.hidden) clearTimeout(timer); else load();
|
||||||
|
});
|
||||||
|
if (Object.keys(rows).length) schedule();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -3,14 +3,18 @@
|
|||||||
<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?v={{.ServerVersion}}-2">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div class="brand">LPRA · Изменить машину</div>
|
<div class="brand">LPRA · Изменить машину</div>
|
||||||
<nav>
|
<nav>
|
||||||
<a href="/">← Машины</a>
|
<a href="/">← Машины</a>
|
||||||
|
<a href="/agents/{{.Agent.ID}}/terminal">Терминал</a>
|
||||||
|
<a href="/agents/{{.Agent.ID}}/files">Файлы</a>
|
||||||
|
<a href="/agents/{{.Agent.ID}}/forwards">Порты</a>
|
||||||
|
<a href="/agents/{{.Agent.ID}}/status">Статус</a>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
@@ -28,6 +32,46 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<div class="card">
|
||||||
<h2>Версия клиента</h2>
|
<h2>Версия клиента</h2>
|
||||||
{{if .Updated}}<div class="err" style="background:#1d3a24;border-color:#2b6b3a;color:#b3ffcb">Команда обновления отправлена. Клиент скачает свежий бинарник и перезапустится.</div>{{end}}
|
{{if .Updated}}<div class="err" style="background:#1d3a24;border-color:#2b6b3a;color:#b3ffcb">Команда обновления отправлена. Клиент скачает свежий бинарник и перезапустится.</div>{{end}}
|
||||||
@@ -50,7 +94,7 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Удаление</h2>
|
<h2>Удаление</h2>
|
||||||
<p class="muted">Удаляет машину из панели. Сервис на самой машине продолжит работать,
|
<p class="muted">Удаляет машину из панели. Сервис на самой машине продолжит работать,
|
||||||
но перестанет быть виден; чтобы подключиться снова — переустановите агента.</p>
|
но перестанет быть виден; чтобы подключиться снова - переустановите агента.</p>
|
||||||
<form method="post" action="/agents/{{.Agent.ID}}/delete"
|
<form method="post" action="/agents/{{.Agent.ID}}/delete"
|
||||||
onsubmit="return confirm('Удалить машину {{.Agent.Name}}?')">
|
onsubmit="return confirm('Удалить машину {{.Agent.Name}}?')">
|
||||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||||
|
|||||||
@@ -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,11 +44,13 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div class="brand">LPRA · Файлы — {{.Agent.Name}} <span class="muted">({{.Agent.Hostname}})</span></div>
|
<div class="brand">LPRA · Файлы - {{.Agent.Name}} <span class="muted">({{.Agent.Hostname}})</span></div>
|
||||||
<nav>
|
<nav>
|
||||||
<a href="/">← Машины</a>
|
<a href="/">← Машины</a>
|
||||||
<a href="/agents/{{.Agent.ID}}/terminal">Терминал</a>
|
<a href="/agents/{{.Agent.ID}}/terminal">Терминал</a>
|
||||||
<a href="/agents/{{.Agent.ID}}/forwards">Порты</a>
|
<a href="/agents/{{.Agent.ID}}/forwards">Порты</a>
|
||||||
|
<a href="/agents/{{.Agent.ID}}/status">Статус</a>
|
||||||
|
<a href="/agents/{{.Agent.ID}}/edit">Изменить</a>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
<div class="wrap" style="max-width:1100px">
|
<div class="wrap" style="max-width:1100px">
|
||||||
@@ -78,7 +80,7 @@
|
|||||||
<tbody id="list"></tbody>
|
<tbody id="list"></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<p class="muted" id="hint" style="font-size:13px">Перетащите файлы сюда для загрузки. Клик по файлу — просмотр (правка, если < 2 КБ).</p>
|
<p class="muted" id="hint" style="font-size:13px">Перетащите файлы сюда для загрузки. Клик по файлу - просмотр и правка.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<dialog id="viewer">
|
<dialog id="viewer">
|
||||||
@@ -107,7 +109,7 @@
|
|||||||
}).then(r => r.json()).catch(e => ({ok:false, error:'ошибка сети'})).finally(() => setBusy(false));
|
}).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).
|
// 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">';
|
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);
|
msg.textContent = 'Размер: '+fmtSize(d.size);
|
||||||
} else {
|
} else {
|
||||||
ta.value = new TextDecoder().decode(b64ToBytes(d.content));
|
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;
|
ta.readOnly = !editable;
|
||||||
save.style.display = editable ? '' : 'none';
|
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 () => {
|
save.onclick = async () => {
|
||||||
const bytes = new TextEncoder().encode(ta.value);
|
const bytes = new TextEncoder().encode(ta.value);
|
||||||
const w = await api('write', {path: join(cwd, e.name), data: bytesToB64(bytes)});
|
const w = await api('write', {path: join(cwd, e.name), data: bytesToB64(bytes)});
|
||||||
|
|||||||
@@ -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; }
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
.frow select, .frow input, .frow button {
|
.frow select, .frow input, .frow button {
|
||||||
height:40px; box-sizing:border-box; font-size:14px; line-height:1; margin:0;
|
height:40px; box-sizing:border-box; font-size:14px; line-height:1; margin:0;
|
||||||
}
|
}
|
||||||
|
label.check { display:inline-flex; align-items:center; gap:8px; margin:14px 0 0; color:var(--text); font-size:14px; cursor:pointer; }
|
||||||
.frow select {
|
.frow select {
|
||||||
padding:0 32px 0 12px; border-radius:8px; border:1px solid var(--border);
|
padding:0 32px 0 12px; border-radius:8px; border:1px solid var(--border);
|
||||||
background:#0c0f13; color:var(--text);
|
background:#0c0f13; color:var(--text);
|
||||||
@@ -20,17 +21,19 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div class="brand">LPRA · Порты — {{.Agent.Name}} <span class="muted">({{.Agent.Hostname}})</span></div>
|
<div class="brand">LPRA · Порты - {{.Agent.Name}} <span class="muted">({{.Agent.Hostname}})</span></div>
|
||||||
<nav>
|
<nav>
|
||||||
<a href="/">← Машины</a>
|
<a href="/">← Машины</a>
|
||||||
<a href="/agents/{{.Agent.ID}}/terminal">Терминал</a>
|
<a href="/agents/{{.Agent.ID}}/terminal">Терминал</a>
|
||||||
<a href="/agents/{{.Agent.ID}}/files">Файлы</a>
|
<a href="/agents/{{.Agent.ID}}/files">Файлы</a>
|
||||||
|
<a href="/agents/{{.Agent.ID}}/status">Статус</a>
|
||||||
|
<a href="/agents/{{.Agent.ID}}/edit">Изменить</a>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<h1>Проброс портов</h1>
|
<h1>Проброс портов</h1>
|
||||||
<p class="muted">Порт сервиса на машине-клиенте становится доступен на порту хоста.
|
<p class="muted">Порт сервиса на машине-клиенте становится доступен на порту хоста.
|
||||||
Клиент за NAT — трафик идёт исходящим подключением агента.</p>
|
Клиент за NAT - трафик идёт исходящим подключением агента.</p>
|
||||||
{{if .Error}}<div class="err">{{.Error}}</div>{{end}}
|
{{if .Error}}<div class="err">{{.Error}}</div>{{end}}
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -44,7 +47,7 @@
|
|||||||
<td><code>{{.Proto}}</code></td>
|
<td><code>{{.Proto}}</code></td>
|
||||||
<td class="muted">:{{.HostPort}}</td>
|
<td class="muted">:{{.HostPort}}</td>
|
||||||
<td class="muted">127.0.0.1:{{.ClientPort}}</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">
|
<td style="text-align:right">
|
||||||
<form method="post" action="/forwards/{{.ID}}/delete" style="display:inline"
|
<form method="post" action="/forwards/{{.ID}}/delete" style="display:inline"
|
||||||
onsubmit="return confirm('Удалить правило?')">
|
onsubmit="return confirm('Удалить правило?')">
|
||||||
@@ -70,8 +73,8 @@
|
|||||||
<label>Тип</label>
|
<label>Тип</label>
|
||||||
<select name="proto" id="proto">
|
<select name="proto" id="proto">
|
||||||
<option value="http">http (long-poll, работает везде)</option>
|
<option value="http">http (long-poll, работает везде)</option>
|
||||||
<option value="tcp">tcp (прямой, низкая задержка, порт 8446)</option>
|
<option value="tcp">tcp (прямой, низкая задержка, порт {{.TunnelPort}})</option>
|
||||||
<option value="udp">udp (прямой, порт 8446)</option>
|
<option value="udp">udp (прямой, порт {{.TunnelPort}})</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="fld">
|
<div class="fld">
|
||||||
@@ -88,6 +91,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<button type="submit">Создать</button>
|
<button type="submit">Создать</button>
|
||||||
</div>
|
</div>
|
||||||
|
{{if .UfwEnabled}}
|
||||||
|
<label class="check" title="Порт не будет открыт в ufw, и при удалении правила в ufw ничего не изменится">
|
||||||
|
<input type="checkbox" name="no_ufw" value="1"> Без ufw правила
|
||||||
|
</label>
|
||||||
|
{{end}}
|
||||||
</form>
|
</form>
|
||||||
<p class="muted" id="note" style="font-size:13px; margin-top:12px"></p>
|
<p class="muted" id="note" style="font-size:13px; margin-top:12px"></p>
|
||||||
</div>
|
</div>
|
||||||
@@ -95,18 +103,18 @@
|
|||||||
<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 (через nginx :8445). Работает везде, где доступна панель, но задержка выше.</li>
|
<li><b>http</b> - туннель поверх HTTP long-poll (через адрес панели, порт {{.PanelPort}}). Работает везде, где доступна панель, но задержка выше.</li>
|
||||||
<li><b>tcp</b> — прямой TCP-мультиплексор через порт <b>8446</b> хоста. Минимальная задержка, keep-alive, TCP_NODELAY. Нужно открыть 8446/tcp на хосте.</li>
|
<li><b>tcp</b> - прямой TCP-мультиплексор через порт <b>{{.TunnelPort}}</b> хоста. Минимальная задержка, keep-alive, TCP_NODELAY. Нужно открыть {{.TunnelPort}}/tcp на хосте.</li>
|
||||||
<li><b>udp</b> — прямой UDP через порт <b>8446</b> хоста. Нужно открыть 8446/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:8446</code>; несколько клиентов и портов мультиплексируются на нём.</p>
|
<p class="muted" style="font-size:13px">Для tcp/udp агент держит исходящее соединение к <code>host:{{.TunnelPort}}</code>; несколько клиентов и портов мультиплексируются на нём.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
var notes = {
|
var notes = {
|
||||||
http: 'Трафик идёт через :8445 (long-poll). Порт 8446 не требуется.',
|
http: 'Трафик идёт через порт панели {{.PanelPort}} (long-poll). Порт {{.TunnelPort}} не требуется.',
|
||||||
tcp: 'Требуется открытый порт 8446/tcp на хосте. Самая низкая задержка.',
|
tcp: 'Требуется открытый порт {{.TunnelPort}}/tcp на хосте. Самая низкая задержка.',
|
||||||
udp: 'Требуется открытый порт 8446/udp на хосте.'
|
udp: 'Требуется открытый порт {{.TunnelPort}}/udp на хосте.'
|
||||||
};
|
};
|
||||||
var sel = document.getElementById('proto'), note = document.getElementById('note');
|
var sel = document.getElementById('proto'), note = document.getElementById('note');
|
||||||
function upd(){ note.textContent = notes[sel.value] || ''; }
|
function upd(){ note.textContent = notes[sel.value] || ''; }
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Настройки — LPRA</title>
|
<title>Настройки - LPRA</title>
|
||||||
<link rel="stylesheet" href="/static/app.css">
|
<link rel="stylesheet" href="/static/app.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
</header>
|
</header>
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<h1>Настройки</h1>
|
<h1>Настройки</h1>
|
||||||
|
{{if .Error}}<div class="err">{{.Error}}</div>{{end}}
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Установка на новую машину</h2>
|
<h2>Установка на новую машину</h2>
|
||||||
@@ -49,6 +50,26 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</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">
|
<div class="card">
|
||||||
<h2>Enroll-секрет</h2>
|
<h2>Enroll-секрет</h2>
|
||||||
<p class="muted">Общий секрет для регистрации новых агентов.</p>
|
<p class="muted">Общий секрет для регистрации новых агентов.</p>
|
||||||
@@ -94,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>
|
||||||
@@ -103,7 +124,8 @@
|
|||||||
Статус 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 — также порт 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">
|
<form method="post" action="/settings/ufw/toggle" style="display:inline">
|
||||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||||
<input type="hidden" name="enabled" value="{{if .UfwEnabled}}0{{else}}1{{end}}">
|
<input type="hidden" name="enabled" value="{{if .UfwEnabled}}0{{else}}1{{end}}">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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,11 +13,13 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div class="brand">LPRA · Терминал — {{.Agent.Name}} <span class="muted">({{.Agent.Hostname}})</span></div>
|
<div class="brand">LPRA · Терминал - {{.Agent.Name}} <span class="muted">({{.Agent.TerminalUser}}@{{.Agent.Hostname}})</span></div>
|
||||||
<nav>
|
<nav>
|
||||||
<a href="/">← Машины</a>
|
<a href="/">← Машины</a>
|
||||||
<a href="/agents/{{.Agent.ID}}/files">Файлы</a>
|
<a href="/agents/{{.Agent.ID}}/files">Файлы</a>
|
||||||
<a href="/agents/{{.Agent.ID}}/forwards">Порты</a>
|
<a href="/agents/{{.Agent.ID}}/forwards">Порты</a>
|
||||||
|
<a href="/agents/{{.Agent.ID}}/status">Статус</a>
|
||||||
|
<a href="/agents/{{.Agent.ID}}/edit">Изменить</a>
|
||||||
<span id="status" class="muted" style="margin-left:28px">подключение…</span>
|
<span id="status" class="muted" style="margin-left:28px">подключение…</span>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
@@ -78,7 +80,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,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.
|
||||||
|
|||||||
+14
@@ -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)
|
||||||
Reference in new issue
Block a user