Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fa3de757c | ||
|
|
0279c1d887 | ||
|
|
0ccac0bc82 | ||
|
|
e5a45b872a | ||
|
|
9e684605ef | ||
|
|
13897c7478 | ||
|
|
4d2cfe8244 | ||
|
|
25ffa60d09 | ||
|
|
baee90b564 | ||
|
|
79828e3f8a | ||
|
|
021da93229 | ||
|
|
36dc12b69d | ||
|
|
1297ab36d0 | ||
|
|
fd382d6313 | ||
|
|
fc488717d3 | ||
|
|
dbb46173a3 | ||
|
|
b47e824692 | ||
|
|
5eca9ca215 | ||
|
|
467bd3cb4f | ||
|
|
18d6684c55 | ||
|
|
1a38c2ac18 | ||
|
|
5a4dc596d5 | ||
|
|
5a204ae9bb | ||
|
|
119b524825 | ||
|
|
29353e5e6a | ||
|
|
47f1b2e55b | ||
|
|
73bb720ec6 | ||
|
|
6dd1120a41 | ||
|
|
67222e2e05 | ||
|
|
e7b5d28470 | ||
|
|
d2d6fe0100 | ||
|
|
e983fc179a | ||
|
|
89d5bfd3ab | ||
|
|
55c37a0097 | ||
|
|
cefdae267b | ||
|
|
44e7137d86 | ||
|
|
10ffa393ca | ||
|
|
114c6fc530 | ||
|
|
0aa066fd01 | ||
|
|
21436d5c73 | ||
|
|
65359f5497 | ||
|
|
8eb2bbd35d | ||
|
|
55db8f03b3 | ||
|
|
e9a845a6ea | ||
|
|
23e382bf7f | ||
|
|
438b20dae0 | ||
|
|
19f8b5291e | ||
|
|
c911143eb2 | ||
|
|
1a90af7800 | ||
|
|
92c9616826 | ||
|
|
2db44e53f7 | ||
|
|
68ede6ab25 | ||
|
|
d448c1e3a4 | ||
|
|
3d6b04e78a | ||
|
|
e1f5595afc | ||
|
|
3e46116a9b | ||
|
|
10b8995041 | ||
|
|
225b7ecfcc | ||
|
|
cb2673d941 | ||
|
|
cf855fdc23 | ||
|
|
d456ffe757 | ||
|
|
81a2c96e0b | ||
|
|
e0dc15740d | ||
|
|
fbd8f30de3 | ||
|
|
67c02ad6b4 | ||
|
|
5155bc36cd | ||
|
|
ab8751d436 | ||
|
|
6441b4329d | ||
|
|
189050b2b6 | ||
|
|
fd75f324b8 | ||
|
|
789e1da93b | ||
|
|
87921a1381 | ||
|
|
b51363bf72 | ||
|
|
201d63ee1e | ||
|
|
5e5718b397 | ||
|
|
c675bb0ec7 | ||
|
|
a02fdc01b0 | ||
|
|
8ba2dd1f1b | ||
|
|
171e14176a | ||
|
|
86cb0226ab | ||
|
|
7f4d645f19 | ||
|
|
f3cb7701f2 | ||
|
|
328ade651c | ||
|
|
5e6e0b8a3a | ||
|
|
bcfe909aaa | ||
|
|
08fc2d79a8 | ||
|
|
7cb9da8569 | ||
|
|
93673d3570 | ||
|
|
3dde07f403 |
No files matched your search
@@ -1,51 +1,151 @@
|
||||
# LPRA — LongPolling Remote Access
|
||||
# LPRA - LongPolling Remote Access
|
||||
|
||||
Лёгкий self-hosted сервис для доступа к Linux-машинам **без белого IP** (за NAT).
|
||||
Агент на клиенте сам исходяще подключается к серверу по HTTP long-polling; админ
|
||||
в веб-панели видит список машин и открывает по каждой **root-терминал в браузере**.
|
||||
Агент на клиенте сам исходяще подключается к серверу - по WebSocket, а где он не проходит,
|
||||
по HTTP long-polling. В веб-панели видно список машин, и по каждой есть терминал в браузере,
|
||||
файловый менеджер, проброс портов и статус.
|
||||
|
||||
```
|
||||
браузер ──WS──> [nginx :8445 TLS] ──> [Go server 127.0.0.1:8091] <──long-poll── агент (root, за NAT)
|
||||
│
|
||||
SQLite
|
||||
браузер ──HTTPS/WS──> [Cloudflare] ──> [nginx TLS] ──> [Go server 127.0.0.1:8091] <──WS / long-poll── агент (за NAT)
|
||||
│ ▲
|
||||
SQLite └──── прямой TCP/UDP-туннель :8446 (проброс tcp/udp)
|
||||
```
|
||||
|
||||
## Возможности
|
||||
- **Терминал** - вкладки на нескольких машинах сразу, до четырёх терминалов в одной вкладке
|
||||
(перетаскиванием, с изменяемыми размерами), переименование вкладок. На телефоне - панель
|
||||
клавиш (Esc, Ctrl, Alt, стрелки и т.д.).
|
||||
- **SSH-терминал** - в меню «+»: агент открывает SSH-соединение к другому хосту (по паролю,
|
||||
ключу - можно загрузить файл - или своими ключами из `~/.ssh`), шелл открывается во вкладке,
|
||||
а шаги подключения видны в статусе. Ключ хоста запоминается при первом входе
|
||||
(`ssh_known_hosts` рядом с `state.json` агента); если он сменится, вход откажет.
|
||||
- **Файлы** - просмотр, редактор с подсветкой, hex-редактор, предпросмотр картинок, видео, PDF,
|
||||
баз SQLite с правкой (значение по клику, добавление и удаление строк, вкладка SQL; агент меняет базу через сам SQLite, так что работающая база не ломается); загрузка и скачивание до 512 МБ кусками по 4 МБ (проходит через Cloudflare);
|
||||
свойства (chmod/chown), zip/unzip.
|
||||
- **Перемещение и копирование** - внутри машины или, кнопкой «сеть» в том же окне, **на другую
|
||||
машину или на SSH-хост**: папка уходит потоком tar без промежуточных файлов, с прогрессом,
|
||||
скоростью и отменой. «Переместить» удаляет исходник после успешной отправки. Правила для
|
||||
существующего: сообщить / заменить / оставить как есть.
|
||||
- **Проброс портов** - `http` через панель, `tcp`/`udp` через прямой туннель.
|
||||
- **Статус** - нагрузка, память, диски, сеть, тест скорости канала до сервера.
|
||||
- **Вход** - пароль и/или ключи доступа (passkeys); пароль можно выключить, когда есть ключ.
|
||||
- **PIN-код** (необязательный, 4 цифры) - спрашивается в каждой новой вкладке браузера, но не при
|
||||
перезагрузке. На устройстве можно не спрашивать его 5/15/30/60 минут после ввода.
|
||||
Пять неверных попыток - выход из сессии.
|
||||
|
||||
## Пользователи и роли
|
||||
- **Администратор** видит всё: все машины, настройки, пользователей, ufw.
|
||||
- **Пользователь** видит только выданные ему машины (терминал, файлы, порты, статус, «Изменить» без удаления);
|
||||
настроек, других пользователей и чужих машин для него нет.
|
||||
|
||||
Роль и машины задаются в «Настройки → Пользователи» (квадратная кнопка у пользователя открывает список машин).
|
||||
Там же галочка «Пароль» включает или выключает вход по паролю (выключить можно, когда у человека есть ключ доступа),
|
||||
а «Закрепить» не даёт ему поменять это самому. Последнего администратора нельзя удалить или понизить.
|
||||
|
||||
## Компоненты
|
||||
- **Сервер** (`cmd/server`) — админ-панель + точка встречи для агентов. За nginx (TLS).
|
||||
- **Агент** (`cmd/agent`) — Linux-бинарь, systemd-сервис от root, PTY по запросу.
|
||||
- **install.sh** — интерактивный установщик, отдаётся сервером как статика.
|
||||
- **Сервер** (`cmd/server`) - панель и точка встречи для агентов. Ставится за nginx (TLS).
|
||||
- **Агент** (`cmd/agent`) - бинарь для Linux (amd64/arm64), systemd-сервис от root.
|
||||
- **install.sh** - установщик агента, отдаётся сервером.
|
||||
|
||||
## Сборка
|
||||
```sh
|
||||
./build.sh
|
||||
```
|
||||
Получаются `bin/lpra-server` и `dist/agent-linux-{amd64,arm64}`.
|
||||
Получаются `bin/lpra-server*` и `dist/agent-linux-{amd64,arm64}`.
|
||||
Версия задаётся в одном месте - `VERSION.txt`, она вшивается в сервер и агент.
|
||||
|
||||
## Запуск сервера
|
||||
```sh
|
||||
./bin/lpra-server -addr 127.0.0.1:8091 -dist ./dist -db ./lpra.db
|
||||
```
|
||||
Поставьте перед ним nginx по `deploy/nginx.conf.example` (TLS, long-poll/WS).
|
||||
Локально без TLS можно добавить `-insecure-cookies`.
|
||||
| Флаг | Переменная | По умолчанию | |
|
||||
|---|---|---|---|
|
||||
| `-addr` | `LPRA_ADDR` | `127.0.0.1:8091` | адрес HTTP (за nginx) |
|
||||
| `-db` | `LPRA_DB` | `lpra.db` | база SQLite |
|
||||
| `-dist` | `LPRA_DIST` | `dist` | бинарники агента для `/download/` |
|
||||
| `-tunnel` | `LPRA_TUNNEL_ADDR` | `:8446` | прямой туннель TCP+UDP (пусто - выключен) |
|
||||
| `-insecure-cookies` | `LPRA_INSECURE_COOKIES=1` | | куки без Secure - только для локального запуска без TLS |
|
||||
|
||||
Первый запуск: откройте `/setup` — мастер создаст администратора. Дальше `/settings`:
|
||||
адрес хоста (по умолчанию `https://localhost:8445`), enroll-секрет и готовая
|
||||
команда установки.
|
||||
Пример юнита - `deploy/lpra-server.service`.
|
||||
|
||||
## Установка агента на клиент
|
||||
Одной командой (под root):
|
||||
```sh
|
||||
curl -fsSL https://localhost:8445/install.sh | sudo HOST=https://localhost:8445 ENROLL_SECRET=<секрет> sh
|
||||
Первый запуск: откройте `/setup` - мастер создаст администратора. Дальше в `/settings`:
|
||||
адрес хоста, enroll-секрет, короткий код установки и готовая команда.
|
||||
|
||||
### nginx
|
||||
Конфиг - `deploy/nginx.conf.example`. Обязательно:
|
||||
- заголовки `Upgrade` / `Connection` (через `map $http_upgrade $connection_upgrade`) - без них
|
||||
WebSocket не пройдёт, и агенты и терминалы останутся на long-polling (работает, но медленнее
|
||||
на больших выводах);
|
||||
- `proxy_read_timeout` больше 25 с (удержание long-poll), `proxy_buffering off`.
|
||||
|
||||
### Прямой адрес (без Cloudflare)
|
||||
В «Настройки → Адрес хоста» можно задать второй, прямой адрес - например `https://direct.lpra.example.com`,
|
||||
DNS-запись без проксирования. Тогда:
|
||||
- агенты, не достучавшись до основного адреса 30 секунд, переходят на прямой и каждые 5 минут проверяют,
|
||||
не ожил ли основной; уже установленные агенты получают прямой адрес с сервера сами (он хранится в `state.json`);
|
||||
- установщик берёт адреса с того домена, с которого его скачали: с основного - агент работает через основной
|
||||
с резервом на прямой, с прямого - наоборот (и бинарник качается с прямого); если первый адрес не отвечает,
|
||||
бинарник качается со второго. В «Полных вариантах» есть короткая команда и через прямой адрес;
|
||||
- в «Изменить → Связь с сервером» у клиента, подключённого через прямой адрес, после статуса стоит «(direct)»;
|
||||
- если хост туннеля не задан, агентам отдаётся хост прямого адреса;
|
||||
- ключи доступа основного домена работают и на его поддомене.
|
||||
|
||||
### Cloudflare
|
||||
Работает за проксирующим Cloudflare: WebSocket он пропускает, файлы идут кусками меньше лимита
|
||||
на запрос, статика с хешем в имени кэшируется. Чтобы панель видела настоящие IP (ограничение
|
||||
попыток входа и кода установки считается по адресу), включите в nginx real IP:
|
||||
```nginx
|
||||
real_ip_header CF-Connecting-IP;
|
||||
set_real_ip_from 173.245.48.0/20; # и остальные диапазоны с https://www.cloudflare.com/ips/
|
||||
```
|
||||
Или интерактивно (спросит секрет, а если агент уже стоит — предложит удалить):
|
||||
Прямой туннель (`:8446`) - это не HTTP, через проксирующий Cloudflare он не идёт: в
|
||||
«Настройки → Прямой туннель» укажите для агентов IP хоста или DNS-имя без проксирования.
|
||||
|
||||
## Установка агента
|
||||
Короткой командой из `/settings` (код из 3-24 букв и цифр, кнопка «Новый код» меняет его):
|
||||
```sh
|
||||
curl -fsSL https://localhost:8445/install.sh | sudo sh
|
||||
curl -fsSL https://panel.example/i/<код> | sudo sh
|
||||
```
|
||||
Или полной:
|
||||
```sh
|
||||
curl -fsSL https://panel.example/install.sh | sudo HOST=https://panel.example ENROLL_SECRET=<секрет> sh
|
||||
```
|
||||
Без параметров установщик спросит секрет, а если агент уже стоит - предложит удалить.
|
||||
Обновить агента можно кнопкой в панели («Изменить» → обновить).
|
||||
|
||||
## Связь агента с сервером
|
||||
В «Изменить» → «Связь с сервером» для каждой машины:
|
||||
- **auto** (по умолчанию) - WebSocket; если он не подключается, long-polling ровно минуту, потом
|
||||
снова попытка WebSocket;
|
||||
- **ws** - только WebSocket;
|
||||
- **lp** - только long-polling.
|
||||
|
||||
## Проброс портов и прямой туннель
|
||||
Правила создаются на странице машины «Порты». Режим `http` идёт через адрес панели,
|
||||
`tcp`/`udp` - через прямой туннель: агент держит исходящее соединение к порту туннеля хоста,
|
||||
по нему мультиплексируются все соединения.
|
||||
|
||||
Порт туннеля и хост для агентов меняются в `/settings` → «Прямой туннель»; агенты забирают их
|
||||
сами, переустановка не нужна. Локальные `TUNNEL_ADDR` / `TUNNEL_PORT` в окружении агента имеют
|
||||
приоритет (`TUNNEL_ADDR=off` отключает туннель).
|
||||
|
||||
Если на хосте активен ufw и сервер запущен от root, панель сама открывает порты правил и порт
|
||||
туннеля, а при удалении правила закрывает. При `ProtectSystem=strict` в юните нужно
|
||||
`ReadWritePaths=/etc/ufw /run`.
|
||||
|
||||
## Пользователь терминала
|
||||
По умолчанию терминал открывается от пользователя агента (root). В «Изменить машину» можно выбрать
|
||||
другого пользователя или создать нового (с паролем, по желанию с sudo) - если агент запущен от root.
|
||||
Файловый менеджер работает от пользователя агента.
|
||||
|
||||
## Безопасность
|
||||
- Пароли админов — bcrypt; сессии в HttpOnly/Secure/SameSite куках; CSRF на формах; rate-limit логина.
|
||||
- Агенты: общий enroll-секрет → индивидуальный токен (хранится хешем, сверка constant-time).
|
||||
- Открытие терминала пишется в таблицу `audit` (терминал даёт root-доступ).
|
||||
- Пароли админов - bcrypt; сессии в HttpOnly/Secure/SameSite куках; CSRF на формах;
|
||||
ограничение попыток входа, PIN и кода установки.
|
||||
- Ключи доступа (WebAuthn) - вход без пароля; пароль можно выключить совсем.
|
||||
- Агенты: общий enroll-секрет → индивидуальный токен (хранится хешем, сверка constant-time);
|
||||
туннель авторизуется HMAC от токена.
|
||||
- SSH-пароли и ключи передаются агенту один раз для подключения и нигде не сохраняются.
|
||||
- Открытие терминала и SSH, перенос файлов на другие машины, смена пользователя пишутся в `audit`.
|
||||
|
||||
## Скриншоты
|
||||
<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.1.2
|
||||
@@ -5,10 +5,9 @@ set -eu
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
VERSION="${VERSION:-$(cat VERSION.txt 2>/dev/null || echo 0.0.0)}"
|
||||
PKG="github.com/x-grk/longpolling-remote-access"
|
||||
LDFLAGS="-s -w -X $PKG/internal/server.Version=$VERSION -X $PKG/internal/agent.Version=$VERSION"
|
||||
echo ">> version $VERSION"
|
||||
# The version comes from VERSION.txt, which is embedded into every binary.
|
||||
LDFLAGS="-s -w"
|
||||
echo ">> version $(cat VERSION.txt)"
|
||||
|
||||
mkdir -p dist bin
|
||||
|
||||
|
||||
+16
-5
@@ -10,19 +10,25 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/x-grk/longpolling-remote-access/internal/agent"
|
||||
"github.com/x-grk/longpolling-remote-access/internal/tunnel"
|
||||
)
|
||||
|
||||
func main() {
|
||||
host := envOr("HOST", "https://REDACTED:8445")
|
||||
cfg := agent.Config{
|
||||
Host: host,
|
||||
Fallback: strings.TrimRight(os.Getenv("FALLBACK_HOST"), "/"),
|
||||
EnrollSecret: os.Getenv("ENROLL_SECRET"),
|
||||
Name: os.Getenv("AGENT_NAME"),
|
||||
StatePath: envOr("STATE_PATH", "/etc/lpagent/state.json"),
|
||||
TunnelAddr: tunnelAddr(host),
|
||||
// A locally set address wins over the one from the panel settings.
|
||||
TunnelPinned: os.Getenv("TUNNEL_ADDR") != "" || os.Getenv("TUNNEL_PORT") != "",
|
||||
}
|
||||
if cfg.EnrollSecret == "" {
|
||||
// Only required for the very first run (before enrollment).
|
||||
@@ -34,7 +40,11 @@ func main() {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
if err := agent.New(cfg).Run(ctx); err != nil {
|
||||
err := agent.New(cfg).Run(ctx)
|
||||
// Stopping (an update, a restart): hang the terminals up, so their shells
|
||||
// save their history instead of being killed.
|
||||
agent.CloseTerminals()
|
||||
if err != nil {
|
||||
log.Fatalf("agent: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -46,9 +56,10 @@ func envOr(key, def string) string {
|
||||
return def
|
||||
}
|
||||
|
||||
// tunnelAddr derives the direct-tunnel address (host:8446) from the server URL.
|
||||
// TUNNEL_ADDR overrides it entirely; TUNNEL_PORT overrides just the port.
|
||||
// Set TUNNEL_ADDR=off to disable the direct tunnel.
|
||||
// tunnelAddr derives the direct-tunnel address (host:port) from the server URL.
|
||||
// It is only the starting point: the agent follows the host and port set in
|
||||
// the panel settings, unless TUNNEL_ADDR (the whole address) or TUNNEL_PORT
|
||||
// (just the port) pins it here. Set TUNNEL_ADDR=off to disable the tunnel.
|
||||
func tunnelAddr(hostURL string) string {
|
||||
if v := os.Getenv("TUNNEL_ADDR"); v != "" {
|
||||
if v == "off" {
|
||||
@@ -60,5 +71,5 @@ func tunnelAddr(hostURL string) string {
|
||||
if err != nil || u.Hostname() == "" {
|
||||
return ""
|
||||
}
|
||||
return net.JoinHostPort(u.Hostname(), envOr("TUNNEL_PORT", "8446"))
|
||||
return net.JoinHostPort(u.Hostname(), envOr("TUNNEL_PORT", strconv.Itoa(tunnel.DefaultPort)))
|
||||
}
|
||||
+3
-1
@@ -10,16 +10,18 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/x-grk/longpolling-remote-access/internal/server"
|
||||
"github.com/x-grk/longpolling-remote-access/internal/tunnel"
|
||||
)
|
||||
|
||||
func main() {
|
||||
addr := flag.String("addr", envOr("LPRA_ADDR", "127.0.0.1:8091"), "listen address (behind nginx)")
|
||||
dbPath := flag.String("db", envOr("LPRA_DB", "lpra.db"), "path to SQLite database")
|
||||
distDir := flag.String("dist", envOr("LPRA_DIST", "dist"), "directory with agent binaries served at /download/")
|
||||
tunnelAddr := flag.String("tunnel", envOr("LPRA_TUNNEL_ADDR", ":8446"), "listen address for the direct TCP+UDP forward tunnel (empty disables)")
|
||||
tunnelAddr := flag.String("tunnel", envOr("LPRA_TUNNEL_ADDR", ":"+strconv.Itoa(tunnel.DefaultPort)), "listen address for the direct TCP+UDP forward tunnel (empty disables)")
|
||||
insecureCookies := flag.Bool("insecure-cookies", envOr("LPRA_INSECURE_COOKIES", "") == "1",
|
||||
"do not mark cookies Secure (plain-HTTP local testing only)")
|
||||
flag.Parse()
|
||||
|
||||
+17
-20
@@ -1,24 +1,24 @@
|
||||
# systemd unit for the LPRA host (server).
|
||||
#
|
||||
# Unlike the agent, the server does NOT need root: it listens on a high local
|
||||
# port (127.0.0.1:8091) behind nginx, so run it as an unprivileged user.
|
||||
# The server runs as root so that the panel can open forwarded ports in ufw;
|
||||
# it listens on a high local port (127.0.0.1:8091) behind nginx.
|
||||
#
|
||||
# Install:
|
||||
# sudo useradd --system --home /opt/lpra --shell /usr/sbin/nologin lpra
|
||||
# sudo mkdir -p /opt/lpra /var/lib/lpra
|
||||
# sudo mkdir -p /opt/lpra
|
||||
# sudo cp bin/lpra-server /opt/lpra/
|
||||
# sudo cp -r dist /opt/lpra/dist # agent binaries served at /download/
|
||||
# sudo chown -R lpra:lpra /opt/lpra /var/lib/lpra
|
||||
# sudo cp deploy/lpra-server.service /etc/systemd/system/
|
||||
# sudo systemctl daemon-reload && sudo systemctl enable --now lpra-server
|
||||
#
|
||||
# Config overrides can go in /etc/lpra-server.env (see EnvironmentFile below),
|
||||
# e.g. LPRA_ADDR=127.0.0.1:8091
|
||||
# Direct port forwarding (tcp/udp modes) uses the tunnel port on the host, which
|
||||
# has to be open in the firewall for tcp and udp. (The "http" forward mode and
|
||||
# terminal/files go through nginx :8445 and need nothing extra.) Set the tunnel
|
||||
# address with LPRA_TUNNEL_ADDR, or set it empty to disable.
|
||||
# The port, and the host agents dial for it, can also be changed in the panel
|
||||
# settings; agents pick the new address up from the panel by themselves.
|
||||
#
|
||||
# Direct port forwarding (tcp/udp modes) uses port 8446 on the host — open it in
|
||||
# the firewall: ufw allow 8446/tcp ; ufw allow 8446/udp
|
||||
# (The "http" forward mode and terminal/files go through nginx :8445 and need
|
||||
# nothing extra.) Override with LPRA_TUNNEL_ADDR, or set it empty to disable.
|
||||
# ufw integration (the panel opening forwarded ports itself) needs root and a
|
||||
# writable /etc/ufw and /run - see ReadWritePaths below.
|
||||
|
||||
[Unit]
|
||||
Description=LPRA remote access host (admin panel + agent rendezvous)
|
||||
@@ -26,17 +26,11 @@ After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=lpra
|
||||
Group=lpra
|
||||
WorkingDirectory=/opt/lpra
|
||||
|
||||
# Optional environment overrides; "-" means the file is not required.
|
||||
EnvironmentFile=-/etc/lpra-server.env
|
||||
|
||||
ExecStart=/opt/lpra/lpra-server \
|
||||
-addr 127.0.0.1:8091 \
|
||||
-db /var/lib/lpra/lpra.db \
|
||||
-db /opt/lpra/lpra.db \
|
||||
-dist /opt/lpra/dist
|
||||
|
||||
Restart=always
|
||||
@@ -51,8 +45,11 @@ PrivateDevices=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectControlGroups=true
|
||||
RestrictSUIDSGID=true
|
||||
# The server only needs to write its SQLite database here.
|
||||
ReadWritePaths=/var/lib/lpra
|
||||
# The SQLite database, plus what ufw writes: its rules in /etc/ufw and the
|
||||
# ufw/xtables locks in /run. Without the last two "ufw status" works but
|
||||
# "ufw allow" fails with "'/etc/ufw/user.rules' is not writable".
|
||||
ReadWritePaths=/opt/lpra /etc/ufw /run
|
||||
ReadWritePaths-=/etc/ufw /run
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -4,14 +4,21 @@
|
||||
# Put this in /etc/nginx/sites-available/lp_agent and symlink it into
|
||||
# sites-enabled, then: sudo nginx -t && sudo systemctl reload nginx
|
||||
#
|
||||
# Everything (agent + browser terminal) is plain HTTP long-polling, so NO
|
||||
# WebSocket config is needed. The two settings that matter:
|
||||
# - proxy_buffering off -> deliver long-poll responses immediately
|
||||
# - proxy_read_timeout 120s -> longer than the 25s long-poll hold
|
||||
# Agents connect by WebSocket (from 0.1.0), with HTTP long polling to fall
|
||||
# back on; the browser uses plain HTTP. The settings that matter:
|
||||
# - Upgrade / Connection headers -> let the agents' WebSocket through
|
||||
# - proxy_buffering off -> deliver long-poll responses immediately
|
||||
# - proxy_read_timeout 300s -> longer than the 25s long-poll hold
|
||||
#
|
||||
# NOTE: proxy_pass MUST start with "http://". A bare "proxy_pass 127.0.0.1:8091;"
|
||||
# causes nginx error: [emerg] invalid URL prefix.
|
||||
|
||||
# "Connection: upgrade" for a WebSocket, "close" for everything else.
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 8445 ssl;
|
||||
server_name REDACTED;
|
||||
@@ -21,7 +28,8 @@ server {
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
|
||||
|
||||
# Agent binaries can be large; allow the download.
|
||||
# Files go through the panel in 4 MB pieces; 64m leaves room for a client
|
||||
# that predates the pieces, which takes a file of up to 32 MB in one request.
|
||||
client_max_body_size 64m;
|
||||
|
||||
location / {
|
||||
@@ -32,11 +40,17 @@ server {
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
# Agents' WebSocket.
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
|
||||
# Long-poll behaviour: no buffering, generous read timeout.
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 120s;
|
||||
proxy_send_timeout 120s;
|
||||
# Pass uploads on as they arrive: the speed test through the browser
|
||||
# times them on the client.
|
||||
proxy_request_buffering off;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
}
|
||||
}
|
||||
@@ -3,18 +3,29 @@ module github.com/x-grk/longpolling-remote-access
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
github.com/coder/websocket v1.8.12
|
||||
github.com/creack/pty v1.1.21
|
||||
golang.org/x/crypto v0.31.0
|
||||
golang.org/x/sys v0.28.0
|
||||
modernc.org/sqlite v1.24.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||
github.com/mattn/go-isatty v0.0.16 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/sys v0.28.0 // indirect
|
||||
golang.org/x/mod v0.3.0 // indirect
|
||||
golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
|
||||
lukechampine.com/uint128 v1.2.0 // indirect
|
||||
modernc.org/cc/v3 v3.40.0 // indirect
|
||||
modernc.org/ccgo/v3 v3.16.13 // indirect
|
||||
modernc.org/libc v1.22.5 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
modernc.org/memory v1.5.0 // indirect
|
||||
modernc.org/opt v0.1.3 // indirect
|
||||
modernc.org/strutil v1.1.3 // indirect
|
||||
modernc.org/token v1.0.1 // indirect
|
||||
)
|
||||
@@ -1,28 +1,80 @@
|
||||
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
|
||||
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
|
||||
github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0=
|
||||
github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
|
||||
github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
|
||||
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/mod v0.3.0 h1:RM4zey1++hCTbCVQfnWeKs9/IEsaBLA8vTkd0WVtmH4=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78 h1:M8tBwCtWD/cZV9DZpFYRUgaymAYAr+aIUTWzDaM3uPs=
|
||||
golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
|
||||
lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
|
||||
modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw=
|
||||
modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0=
|
||||
modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw=
|
||||
modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY=
|
||||
modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk=
|
||||
modernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ=
|
||||
modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM=
|
||||
modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM=
|
||||
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
|
||||
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
||||
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
||||
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
||||
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||
modernc.org/sqlite v1.24.0 h1:EsClRIWHGhLTCX44p+Ri/JLD+vFGo0QGjasg2/F9TlI=
|
||||
modernc.org/sqlite v1.24.0/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
|
||||
modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY=
|
||||
modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
|
||||
modernc.org/tcl v1.15.2 h1:C4ybAYCGJw968e+Me18oW55kD/FexcHbqH2xak1ROSY=
|
||||
modernc.org/tcl v1.15.2/go.mod h1:3+k/ZaEbKrC8ePv8zJWPtBSW0V7Gg9g8rkmhI1Kfs3c=
|
||||
modernc.org/token v1.0.1 h1:A3qvTqOwexpfZZeyI0FeGPDlSWX5pjZu9hF4lU+EKWg=
|
||||
modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
modernc.org/z v1.7.3 h1:zDJf6iHjrnB+WRD88stbXokugjyc0/pB91ri1gO6LZY=
|
||||
modernc.org/z v1.7.3/go.mod h1:Ipv4tsdxZRbQyLq9Q1M6gdbkxYzdlrciF2Hi/lS7nWE=
|
||||
+254
-23
@@ -13,38 +13,57 @@ import (
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
lpra "github.com/x-grk/longpolling-remote-access"
|
||||
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||
)
|
||||
|
||||
// Version is the agent build version, injected at compile time via -ldflags
|
||||
// (-X .../internal/agent.Version=...).
|
||||
var Version = "dev"
|
||||
// Version is the agent build version (see VERSION.txt).
|
||||
var Version = lpra.Version
|
||||
|
||||
// Config is the agent's runtime configuration (from env / config.env).
|
||||
type Config struct {
|
||||
Host string // e.g. https://REDACTED:8445
|
||||
Fallback string // the direct address, from the installer (FALLBACK_HOST); may be empty
|
||||
EnrollSecret string
|
||||
Name string // optional friendly name
|
||||
StatePath string // where the enrolled id+token are persisted
|
||||
TunnelAddr string // host:port for the direct forward tunnel (e.g. REDACTED:8446); empty disables
|
||||
TunnelAddr string // host:port for the direct forward tunnel (e.g. panel.example:port); empty disables
|
||||
// TunnelPinned keeps TunnelAddr as configured locally (TUNNEL_ADDR /
|
||||
// TUNNEL_PORT) instead of following the address set in the panel.
|
||||
TunnelPinned bool
|
||||
}
|
||||
|
||||
type state struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
Token string `json:"token"`
|
||||
// Fallback is the direct address as the server told it.
|
||||
Fallback string `json:"fallback,omitempty"`
|
||||
}
|
||||
|
||||
// Agent is a running client.
|
||||
type Agent struct {
|
||||
cfg Config
|
||||
http *http.Client
|
||||
stream *http.Client
|
||||
state state
|
||||
stateMu sync.Mutex
|
||||
hosts hostState
|
||||
pollCount int
|
||||
|
||||
// Direct tunnel currently running (see setTunnelAddr).
|
||||
transport atomic.Value // string: auto, ws or lp, as the server says
|
||||
link atomic.Pointer[wsLink]
|
||||
tunMu sync.Mutex
|
||||
tunAddr string
|
||||
tunCancel context.CancelFunc
|
||||
}
|
||||
|
||||
// New builds an agent. It does not perform any network I/O.
|
||||
@@ -71,6 +90,8 @@ func New(cfg Config) *Agent {
|
||||
cfg: cfg,
|
||||
// Timeout must exceed the server's long-poll hold (25s) plus slack.
|
||||
http: &http.Client{Timeout: 60 * time.Second, Transport: tr},
|
||||
// File streams take as long as the file needs.
|
||||
stream: &http.Client{Transport: tr},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,37 +102,164 @@ func (a *Agent) Run(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
if a.state.AgentID == "" || a.state.Token == "" {
|
||||
if err := a.enroll(ctx); err != nil {
|
||||
err := a.enroll(ctx)
|
||||
// The main address out of reach at install: the direct one.
|
||||
if err != nil && a.cfg.Fallback != "" && a.cfg.Fallback != a.cfg.Host {
|
||||
log.Printf("enroll at %s: %v - trying %s", a.cfg.Host, err, a.cfg.Fallback)
|
||||
a.setHost(a.cfg.Fallback)
|
||||
a.hostChanged()
|
||||
err = a.enroll(ctx)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("enroll: %w", err)
|
||||
}
|
||||
}
|
||||
log.Printf("agent %s connected to %s", a.state.AgentID, a.cfg.Host)
|
||||
go a.watchMain(ctx)
|
||||
log.Printf("agent %s connected to %s", a.state.AgentID, a.host())
|
||||
|
||||
// Direct forward tunnel (low-latency TCP mux + UDP) on 8446, if configured.
|
||||
if a.cfg.TunnelAddr != "" {
|
||||
go a.runTunnel(ctx)
|
||||
go a.runUDP(ctx)
|
||||
// Direct forward tunnel (low-latency TCP mux + UDP). Its address comes
|
||||
// from the panel; fall back to the configured one when the server sends
|
||||
// none (tunnel port left at the default on an older server).
|
||||
if a.cfg.TunnelPinned || !a.fetchConfig(ctx) {
|
||||
a.setTunnelAddr(ctx, a.cfg.TunnelAddr)
|
||||
}
|
||||
|
||||
// The control channel: a WebSocket, or long polls. In "auto" a failed or
|
||||
// dropped WebSocket gives way to long polling for a minute, then is tried
|
||||
// again; "ws" keeps trying the WebSocket; "lp" only polls.
|
||||
backoff := time.Second
|
||||
for ctx.Err() == nil {
|
||||
if err := a.pollOnce(ctx); err != nil {
|
||||
log.Printf("poll: %v (retry in %s)", err, backoff)
|
||||
if a.mode() == proto.TransportLP {
|
||||
a.longPoll(ctx, 0)
|
||||
continue
|
||||
}
|
||||
err := a.runWS(ctx)
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
// Moved to the other address: the WebSocket goes there at once.
|
||||
if a.hostChanged() {
|
||||
backoff = time.Second
|
||||
continue
|
||||
}
|
||||
a.failed()
|
||||
if a.hostChanged() {
|
||||
continue
|
||||
}
|
||||
switch a.mode() {
|
||||
case proto.TransportWS:
|
||||
log.Printf("websocket: %v (retry in %s)", err, backoff)
|
||||
select {
|
||||
case <-time.After(backoff):
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
}
|
||||
if backoff < 15*time.Second {
|
||||
backoff *= 2
|
||||
}
|
||||
backoff = min(backoff*2, retryCap)
|
||||
continue
|
||||
case proto.TransportAuto:
|
||||
log.Printf("websocket: %v - long polling, websocket again in a minute", err)
|
||||
}
|
||||
backoff = time.Second
|
||||
a.longPoll(ctx, time.Minute)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// mode is the transport the server set for this agent.
|
||||
func (a *Agent) mode() string {
|
||||
if v, ok := a.transport.Load().(string); ok {
|
||||
return v
|
||||
}
|
||||
return proto.TransportAuto
|
||||
}
|
||||
|
||||
func (a *Agent) applyTransport(v string) {
|
||||
switch v {
|
||||
case proto.TransportAuto, proto.TransportWS, proto.TransportLP:
|
||||
if v != a.mode() {
|
||||
log.Printf("transport: %s", v)
|
||||
a.transport.Store(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// longPoll runs control polls: for d, or with d zero until the transport is
|
||||
// no longer long polling.
|
||||
func (a *Agent) longPoll(ctx context.Context, d time.Duration) {
|
||||
until := time.Now().Add(d)
|
||||
backoff := time.Second
|
||||
// The server was out of reach (a network down, a machine asleep): once
|
||||
// a poll gets through again, "auto" tries the WebSocket at once rather
|
||||
// than at the end of the minute - it was the network, not the WebSocket.
|
||||
lost := false
|
||||
for ctx.Err() == nil {
|
||||
if d > 0 && (time.Now().After(until) || a.mode() == proto.TransportWS) {
|
||||
return
|
||||
}
|
||||
if d == 0 && a.mode() != proto.TransportLP {
|
||||
return
|
||||
}
|
||||
// Back from out of reach in "auto": a quick request tells when the
|
||||
// server answers again, and the WebSocket is tried then; a poll
|
||||
// would sit held on the server first.
|
||||
if lost && d > 0 {
|
||||
if a.reachable(ctx) {
|
||||
a.reached()
|
||||
return
|
||||
}
|
||||
a.failed()
|
||||
if a.hostChanged() {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-time.After(backoff):
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
backoff = min(backoff*2, retryCap)
|
||||
continue
|
||||
}
|
||||
// In "auto" the minute is a minute: a poll still held then is cut
|
||||
// short (the server keeps its job for the next one).
|
||||
reqCtx, cancel := ctx, context.CancelFunc(func() {})
|
||||
if d > 0 {
|
||||
reqCtx, cancel = context.WithDeadline(ctx, until)
|
||||
}
|
||||
err := a.pollOnce(ctx, reqCtx)
|
||||
cut := reqCtx.Err() == context.DeadlineExceeded
|
||||
cancel()
|
||||
if err != nil && cut && ctx.Err() == nil {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
a.failed()
|
||||
if a.hostChanged() {
|
||||
return
|
||||
}
|
||||
log.Printf("poll: %v (retry in %s)", err, backoff)
|
||||
select {
|
||||
case <-time.After(backoff):
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
backoff = min(backoff*2, retryCap)
|
||||
lost = true
|
||||
continue
|
||||
}
|
||||
a.reached()
|
||||
backoff = time.Second
|
||||
}
|
||||
}
|
||||
|
||||
// reachable reports whether the server answers a quick request.
|
||||
func (a *Agent) reachable(ctx context.Context) bool {
|
||||
return a.reachableAt(ctx, a.host())
|
||||
}
|
||||
|
||||
// retryCap bounds the wait between attempts to reach the server, so a
|
||||
// machine back from sleep or a network back up is reconnected in seconds.
|
||||
const retryCap = 5 * time.Second
|
||||
|
||||
func (a *Agent) enroll(ctx context.Context) error {
|
||||
host, _ := os.Hostname()
|
||||
body, _ := json.Marshal(proto.EnrollRequest{
|
||||
@@ -120,8 +268,9 @@ func (a *Agent) enroll(ctx context.Context) error {
|
||||
OS: runtime.GOOS,
|
||||
Arch: runtime.GOARCH,
|
||||
Version: Version,
|
||||
User: currentUser(),
|
||||
})
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, a.cfg.Host+"/api/agent/enroll", bytes.NewReader(body))
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, a.host()+"/api/agent/enroll", bytes.NewReader(body))
|
||||
req.Header.Set("X-Enroll-Secret", a.cfg.EnrollSecret)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := a.http.Do(req)
|
||||
@@ -142,14 +291,17 @@ func (a *Agent) enroll(ctx context.Context) error {
|
||||
}
|
||||
|
||||
// pollOnce performs one control long-poll and dispatches any instruction.
|
||||
func (a *Agent) pollOnce(ctx context.Context) error {
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, a.cfg.Host+"/api/agent/poll", nil)
|
||||
// The poll itself runs on reqCtx, which may end early; what it starts runs on
|
||||
// ctx.
|
||||
func (a *Agent) pollOnce(ctx, reqCtx context.Context) error {
|
||||
req, _ := http.NewRequestWithContext(reqCtx, http.MethodGet, a.host()+"/api/agent/poll", nil)
|
||||
a.authHeaders(req)
|
||||
// Report the version occasionally (not every poll), so the panel can flag
|
||||
// outdated agents without extra traffic.
|
||||
// Report the version and our OS user occasionally (not every poll), so the
|
||||
// panel can flag outdated agents without extra traffic.
|
||||
a.pollCount++
|
||||
if a.pollCount%20 == 1 {
|
||||
req.Header.Set("X-Agent-Version", Version)
|
||||
req.Header.Set("X-Agent-User", currentUser())
|
||||
}
|
||||
resp, err := a.http.Do(req)
|
||||
if err != nil {
|
||||
@@ -166,16 +318,93 @@ func (a *Agent) pollOnce(ctx context.Context) error {
|
||||
if err := json.NewDecoder(resp.Body).Decode(&msg); err != nil {
|
||||
return err
|
||||
}
|
||||
// Dispatch in the background so we keep polling for more work.
|
||||
a.applyTunnelHeader(ctx, resp.Header)
|
||||
a.learnAddrs(resp.Header.Get(proto.MainHeader), resp.Header.Get(proto.FallbackHeader))
|
||||
a.applyTransport(resp.Header.Get(proto.TransportHeader))
|
||||
a.dispatch(ctx, msg)
|
||||
return nil
|
||||
}
|
||||
|
||||
// dispatch starts what a control message asks for, in the background, so the
|
||||
// control channel keeps listening.
|
||||
func (a *Agent) dispatch(ctx context.Context, msg proto.ControlMessage) {
|
||||
switch msg.Type {
|
||||
case proto.CtrlOpenTerminal:
|
||||
go a.runTerminal(ctx, msg.SessionID)
|
||||
go a.runTerminal(ctx, msg.SessionID, msg.User)
|
||||
case proto.CtrlOpenForward:
|
||||
go a.runForward(ctx, msg.SessionID, msg.Port)
|
||||
case proto.CtrlTask:
|
||||
go a.runTask(ctx, msg.TaskID)
|
||||
case proto.CtrlOpenSSH:
|
||||
if msg.SSH != nil {
|
||||
go a.runSSH(ctx, msg.SessionID, *msg.SSH)
|
||||
}
|
||||
}
|
||||
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.host()+"/api/agent/config", nil)
|
||||
a.authHeaders(req)
|
||||
resp, err := a.http.Do(req)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
a.applyTransport(resp.Header.Get(proto.TransportHeader))
|
||||
a.learnAddrs(resp.Header.Get(proto.MainHeader), resp.Header.Get(proto.FallbackHeader))
|
||||
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 {
|
||||
return a.applyTunnelAddr(ctx, h.Get("X-Tunnel-Addr"))
|
||||
}
|
||||
|
||||
func (a *Agent) applyTunnelAddr(ctx context.Context, v string) bool {
|
||||
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.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) {
|
||||
@@ -200,7 +429,9 @@ func (a *Agent) saveState() error {
|
||||
if err := os.MkdirAll(filepath.Dir(a.cfg.StatePath), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
a.stateMu.Lock()
|
||||
b, _ := json.MarshalIndent(a.state, "", " ")
|
||||
a.stateMu.Unlock()
|
||||
return os.WriteFile(a.cfg.StatePath, b, 0o600)
|
||||
}
|
||||
|
||||
|
||||
+53
-2
@@ -9,7 +9,9 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
@@ -17,8 +19,8 @@ import (
|
||||
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||
)
|
||||
|
||||
// fsReadCap bounds how many bytes a view/read returns (5 MiB).
|
||||
const fsReadCap = 5 * 1024 * 1024
|
||||
// fsReadCap bounds how many bytes a view/read returns.
|
||||
const fsReadCap = proto.FSReadCap
|
||||
|
||||
// fsDownloadCap bounds a browser download (kept under the 64 MiB result/body
|
||||
// limit once base64-encoded).
|
||||
@@ -70,6 +72,18 @@ func dispatchFS(env proto.TaskEnvelope) (any, error) {
|
||||
var a proto.FSZipArgs
|
||||
_ = json.Unmarshal(env.Args, &a)
|
||||
return fsZip(a.Paths, a.Dest)
|
||||
case proto.TaskFSStat:
|
||||
var a proto.FSPathArgs
|
||||
_ = json.Unmarshal(env.Args, &a)
|
||||
return fsStat(a.Path)
|
||||
case proto.TaskFSChmod:
|
||||
var a proto.FSChmodArgs
|
||||
_ = json.Unmarshal(env.Args, &a)
|
||||
return fsChmod(a)
|
||||
case proto.TaskFSCopy:
|
||||
var a proto.FSCopyArgs
|
||||
_ = json.Unmarshal(env.Args, &a)
|
||||
return fsCopy(a)
|
||||
default:
|
||||
return nil, fmt.Errorf("неизвестная операция %q", env.Type)
|
||||
}
|
||||
@@ -79,8 +93,18 @@ func fsList(path string) (proto.FSListResult, error) {
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
// "~" / "~name" is where the file manager starts: the home directory, or
|
||||
// the root when there is none to open.
|
||||
home := strings.HasPrefix(path, "~") && !strings.Contains(path, "/")
|
||||
if home {
|
||||
path = homeDir(path[1:])
|
||||
}
|
||||
abs, _ := filepath.Abs(path)
|
||||
entries, err := os.ReadDir(abs)
|
||||
if err != nil && home && abs != "/" {
|
||||
abs = "/"
|
||||
entries, err = os.ReadDir(abs)
|
||||
}
|
||||
if err != nil {
|
||||
return proto.FSListResult{}, err
|
||||
}
|
||||
@@ -222,3 +246,30 @@ func fsZip(paths []string, dest string) (any, error) {
|
||||
}
|
||||
return map[string]string{"dest": dest}, nil
|
||||
}
|
||||
|
||||
// homeDir returns the home directory of the named user ("" = the user the
|
||||
// agent runs as), or "/" when it cannot be told.
|
||||
func homeDir(name string) string {
|
||||
if name != "" {
|
||||
if validUserName(name) {
|
||||
if u, err := user.Lookup(name); err == nil && u.HomeDir != "" {
|
||||
return u.HomeDir
|
||||
}
|
||||
}
|
||||
return "/"
|
||||
}
|
||||
if u, err := user.Current(); err == nil && u.HomeDir != "" {
|
||||
return u.HomeDir
|
||||
}
|
||||
if h := os.Getenv("HOME"); h != "" {
|
||||
return h
|
||||
}
|
||||
// A root service often has neither a passwd entry we can read nor $HOME.
|
||||
if os.Geteuid() == 0 {
|
||||
if runtime.GOOS == "darwin" {
|
||||
return "/var/root"
|
||||
}
|
||||
return "/root"
|
||||
}
|
||||
return "/"
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||
)
|
||||
|
||||
// fsCopy copies or moves Src to Dst, following Conflict when Dst exists.
|
||||
func fsCopy(a proto.FSCopyArgs) (any, error) {
|
||||
src, dst := filepath.Clean(a.Src), filepath.Clean(a.Dst)
|
||||
if a.Src == "" || a.Dst == "" {
|
||||
return nil, errors.New("не указан путь")
|
||||
}
|
||||
if src == dst {
|
||||
return nil, errors.New("откуда и куда - одно и то же")
|
||||
}
|
||||
if strings.HasPrefix(dst, src+string(filepath.Separator)) {
|
||||
return nil, errors.New("нельзя положить папку внутрь неё самой")
|
||||
}
|
||||
if _, err := os.Lstat(src); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := os.Stat(filepath.Dir(dst)); err != nil {
|
||||
return nil, fmt.Errorf("нет папки %s", filepath.Dir(dst))
|
||||
}
|
||||
c := &copier{move: a.Move, conflict: a.Conflict}
|
||||
if err := c.place(src, dst); err != nil {
|
||||
return map[string]int{"done": c.done, "skipped": c.skipped}, err
|
||||
}
|
||||
return map[string]int{"done": c.done, "skipped": c.skipped}, nil
|
||||
}
|
||||
|
||||
type copier struct {
|
||||
move bool
|
||||
conflict string
|
||||
done, skipped int
|
||||
}
|
||||
|
||||
// place puts src at dst. Two folders merge when the rule allows it; anything
|
||||
// else that is in the way stops, is replaced or is kept, by the rule.
|
||||
func (c *copier) place(src, dst string) error {
|
||||
si, err := os.Lstat(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
di, err := os.Lstat(dst)
|
||||
if err == nil {
|
||||
if c.conflict != "replace" && c.conflict != "skip" {
|
||||
return fmt.Errorf("%s уже существует", dst)
|
||||
}
|
||||
if si.IsDir() && di.IsDir() {
|
||||
entries, err := os.ReadDir(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if err := c.place(filepath.Join(src, e.Name()), filepath.Join(dst, e.Name())); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if c.move {
|
||||
// Whatever was skipped stays where it was.
|
||||
_ = os.Remove(src)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if c.conflict == "skip" {
|
||||
c.skipped++
|
||||
return nil
|
||||
}
|
||||
if err := os.RemoveAll(dst); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if !errors.Is(err, fs.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
if c.move {
|
||||
err := os.Rename(src, dst)
|
||||
if err == nil {
|
||||
c.done++
|
||||
return nil
|
||||
}
|
||||
// Another file system: copy, then take the original away.
|
||||
if !errors.Is(err, syscall.EXDEV) {
|
||||
return err
|
||||
}
|
||||
if err := c.copyTree(src, dst); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.RemoveAll(src)
|
||||
}
|
||||
return c.copyTree(src, dst)
|
||||
}
|
||||
|
||||
// copyTree copies a file, a symlink or a whole folder, keeping modes and
|
||||
// times.
|
||||
func (c *copier) copyTree(src, dst string) error {
|
||||
return filepath.WalkDir(src, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, _ := filepath.Rel(src, p)
|
||||
to := filepath.Join(dst, rel)
|
||||
info, err := os.Lstat(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch {
|
||||
case info.Mode()&fs.ModeSymlink != 0:
|
||||
link, err := os.Readlink(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Symlink(link, to); err != nil {
|
||||
return err
|
||||
}
|
||||
case info.IsDir():
|
||||
if err := os.Mkdir(to, info.Mode().Perm()); err != nil && !errors.Is(err, fs.ErrExist) {
|
||||
return err
|
||||
}
|
||||
case info.Mode().IsRegular():
|
||||
if err := copyFile(p, to, info); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return nil // sockets, devices: not copied
|
||||
}
|
||||
c.done++
|
||||
if st, ok := info.Sys().(*syscall.Stat_t); ok {
|
||||
_ = os.Lchown(to, int(st.Uid), int(st.Gid))
|
||||
}
|
||||
if info.Mode()&fs.ModeSymlink == 0 {
|
||||
_ = os.Chtimes(to, info.ModTime(), info.ModTime())
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func copyFile(src, dst string, info fs.FileInfo) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode().Perm())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
out.Close()
|
||||
os.Remove(dst)
|
||||
return err
|
||||
}
|
||||
if err := out.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Chmod(dst, info.Mode()&(fs.ModePerm|fs.ModeSetuid|fs.ModeSetgid|fs.ModeSticky))
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||
)
|
||||
|
||||
// fsStat reports a file's properties, following a symlink to what it points
|
||||
// at, and the user and group names to choose an owner from.
|
||||
func fsStat(path string) (proto.FSStatResult, error) {
|
||||
res := proto.FSStatResult{Path: path}
|
||||
if li, err := os.Lstat(path); err == nil && li.Mode()&os.ModeSymlink != 0 {
|
||||
res.Link, _ = os.Readlink(path)
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
res.IsDir, res.Size, res.MTime = info.IsDir(), info.Size(), info.ModTime().Unix()
|
||||
if st, ok := info.Sys().(*syscall.Stat_t); ok {
|
||||
res.Mode = uint32(st.Mode) & 07777
|
||||
res.UID, res.GID = int(st.Uid), int(st.Gid)
|
||||
} else {
|
||||
res.Mode = uint32(info.Mode().Perm())
|
||||
}
|
||||
res.Owner, res.Group = strconv.Itoa(res.UID), strconv.Itoa(res.GID)
|
||||
if u, err := user.LookupId(res.Owner); err == nil {
|
||||
res.Owner = u.Username
|
||||
}
|
||||
if g, err := user.LookupGroupId(res.Group); err == nil {
|
||||
res.Group = g.Name
|
||||
}
|
||||
res.Users, res.Groups = namesIn("/etc/passwd"), namesIn("/etc/group")
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// namesIn lists the first field of a passwd- or group-style file.
|
||||
func namesIn(file string) []string {
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer f.Close()
|
||||
var out []string
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() && len(out) < 1000 {
|
||||
line := sc.Text()
|
||||
if line == "" || line[0] == '#' {
|
||||
continue
|
||||
}
|
||||
if name, _, ok := strings.Cut(line, ":"); ok && name != "" {
|
||||
out = append(out, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// lookupID resolves a user or group given by name or number; -1 for none.
|
||||
func lookupID(name string, group bool) (int, error) {
|
||||
if name == "" {
|
||||
return -1, nil
|
||||
}
|
||||
if n, err := strconv.Atoi(name); err == nil && n >= 0 {
|
||||
return n, nil
|
||||
}
|
||||
if group {
|
||||
g, err := user.LookupGroup(name)
|
||||
if err != nil {
|
||||
return -1, fmt.Errorf("группа %s не найдена", name)
|
||||
}
|
||||
return strconv.Atoi(g.Gid)
|
||||
}
|
||||
u, err := user.Lookup(name)
|
||||
if err != nil {
|
||||
return -1, fmt.Errorf("пользователь %s не найден", name)
|
||||
}
|
||||
return strconv.Atoi(u.Uid)
|
||||
}
|
||||
|
||||
// fsChmod sets mode, owner and group on a path, and with Recursive on all
|
||||
// that is inside a folder. Symlinks inside are not followed.
|
||||
func fsChmod(a proto.FSChmodArgs) (any, error) {
|
||||
if a.Path == "" {
|
||||
return nil, errors.New("не указан путь")
|
||||
}
|
||||
if a.Recursive && filepath.Clean(a.Path) == "/" {
|
||||
return nil, errors.New("отказ: рекурсивно для корня нельзя")
|
||||
}
|
||||
if a.Mode != nil && *a.Mode > 07777 {
|
||||
return nil, errors.New("неверные права")
|
||||
}
|
||||
uid, err := lookupID(a.Owner, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gid, err := lookupID(a.Group, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
changed, failed := 0, 0
|
||||
var errs []string
|
||||
// The path itself always gets the change; Only picks among what is in it.
|
||||
apply := func(p string, isDir, isLink, self bool) {
|
||||
if !self && ((a.Only == "files" && isDir) || (a.Only == "dirs" && !isDir)) {
|
||||
return
|
||||
}
|
||||
var err error
|
||||
if uid >= 0 || gid >= 0 {
|
||||
err = os.Lchown(p, uid, gid)
|
||||
}
|
||||
// A symlink has no mode of its own: chmod would change its target.
|
||||
if err == nil && a.Mode != nil && !isLink {
|
||||
err = syscall.Chmod(p, *a.Mode)
|
||||
}
|
||||
if err != nil {
|
||||
failed++
|
||||
if len(errs) < 5 {
|
||||
errs = append(errs, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
changed++
|
||||
}
|
||||
info, err := os.Stat(a.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !a.Recursive || !info.IsDir() {
|
||||
apply(a.Path, info.IsDir(), false, true)
|
||||
} else {
|
||||
err = filepath.WalkDir(a.Path, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
failed++
|
||||
if len(errs) < 5 {
|
||||
errs = append(errs, err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
apply(p, d.IsDir(), d.Type()&fs.ModeSymlink != 0, p == a.Path)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
res := map[string]any{"changed": changed, "failed": failed, "errors": errs}
|
||||
if failed > 0 && changed == 0 {
|
||||
return res, errors.New(errs[0])
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||
)
|
||||
|
||||
// Files go to and from the browser in pieces, each a request of its own
|
||||
// through the server: small enough for any proxy in front, and sent again
|
||||
// alone when the link drops it. A few go at once, so a good link is kept as
|
||||
// busy as by one long stream.
|
||||
|
||||
const (
|
||||
// xferWorkers is how many pieces are in flight at once.
|
||||
xferWorkers = 4
|
||||
// xferPieceTimeout bounds one piece's request: a link that slow, or
|
||||
// stuck, gets the piece sent again.
|
||||
xferPieceTimeout = 5 * time.Minute
|
||||
// xferTries bounds how often a piece is tried over a failing link.
|
||||
xferTries = 30
|
||||
)
|
||||
|
||||
// errXferGone is the server saying the transfer is over: the browser left
|
||||
// or gave up.
|
||||
type errXferGone string
|
||||
|
||||
func (e errXferGone) Error() string { return string(e) }
|
||||
|
||||
func (a *Agent) xferURL(id string, i int) string {
|
||||
return a.host() + "/api/agent/fs/xfer/" + id + "/" + strconv.Itoa(i)
|
||||
}
|
||||
|
||||
// xferRequest makes one request for a piece, trying again over a failing link.
|
||||
// A 503 is the server waiting on the other side: asked again at once.
|
||||
func (a *Agent) xferRequest(ctx context.Context, newReq func(context.Context) (*http.Request, error), keep func(*http.Response) error) error {
|
||||
fails := 0
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
rctx, cancel := context.WithTimeout(ctx, xferPieceTimeout)
|
||||
req, err := newReq(rctx)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return err
|
||||
}
|
||||
a.authHeaders(req)
|
||||
resp, err := a.stream.Do(req)
|
||||
if err == nil {
|
||||
switch {
|
||||
case resp.StatusCode == http.StatusGone:
|
||||
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
resp.Body.Close()
|
||||
cancel()
|
||||
return errXferGone(strings.TrimSpace(string(msg)))
|
||||
case resp.StatusCode == http.StatusServiceUnavailable:
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
cancel()
|
||||
continue
|
||||
case resp.StatusCode < 300:
|
||||
err = keep(resp)
|
||||
resp.Body.Close()
|
||||
cancel()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
default:
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
err = errStatus(resp.StatusCode)
|
||||
}
|
||||
}
|
||||
cancel()
|
||||
if fails++; fails >= xferTries {
|
||||
return fmt.Errorf("сеть не держит передачу: %w", err)
|
||||
}
|
||||
select {
|
||||
case <-time.After(min(time.Duration(fails)*time.Second, 10*time.Second)):
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pieces runs fn for every piece, xferWorkers at a time, until one fails.
|
||||
func pieces(ctx context.Context, n int, fn func(ctx context.Context, i int) error) error {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
var (
|
||||
mu sync.Mutex
|
||||
next int
|
||||
first error
|
||||
wg sync.WaitGroup
|
||||
)
|
||||
for w := 0; w < min(xferWorkers, n); w++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
mu.Lock()
|
||||
if first != nil || next >= n {
|
||||
mu.Unlock()
|
||||
return
|
||||
}
|
||||
i := next
|
||||
next++
|
||||
mu.Unlock()
|
||||
if err := fn(ctx, i); err != nil {
|
||||
mu.Lock()
|
||||
if first == nil {
|
||||
first = err
|
||||
cancel()
|
||||
}
|
||||
mu.Unlock()
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
return first
|
||||
}
|
||||
|
||||
func pieceCount(size int64, chunk int) int {
|
||||
if size <= 0 {
|
||||
return 1
|
||||
}
|
||||
return int((size + int64(chunk) - 1) / int64(chunk))
|
||||
}
|
||||
|
||||
// doFSPut writes the file the browser is uploading. It lands in a temporary
|
||||
// file next to the target first, so a broken upload leaves the old file as it
|
||||
// was.
|
||||
func (a *Agent) doFSPut(ctx context.Context, env proto.TaskEnvelope) proto.TaskResult {
|
||||
var args proto.FSXferArgs
|
||||
if err := json.Unmarshal(env.Args, &args); err != nil {
|
||||
return proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
if args.Size < 0 || args.Size > proto.FSFileCap || args.Chunk <= 0 {
|
||||
return proto.TaskResult{Error: "файл больше 512 МБ"}
|
||||
}
|
||||
dir, base := filepath.Split(args.Path)
|
||||
if dir == "" {
|
||||
dir = "."
|
||||
}
|
||||
if info, err := os.Stat(args.Path); err == nil && info.IsDir() {
|
||||
return proto.TaskResult{Error: "это каталог"}
|
||||
}
|
||||
tmp, err := os.CreateTemp(dir, "."+base+".lpra-*")
|
||||
if err != nil {
|
||||
return proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
done := false
|
||||
defer func() {
|
||||
if !done {
|
||||
tmp.Close()
|
||||
os.Remove(tmp.Name())
|
||||
}
|
||||
}()
|
||||
|
||||
// Pieces written so far, to tell the server which it can let go.
|
||||
n := pieceCount(args.Size, args.Chunk)
|
||||
var (
|
||||
mu sync.Mutex
|
||||
got = make([]bool, n)
|
||||
contig int
|
||||
)
|
||||
err = pieces(ctx, n, func(ctx context.Context, i int) error {
|
||||
want := min(int64(args.Chunk), args.Size-int64(i)*int64(args.Chunk))
|
||||
mu.Lock()
|
||||
ack := contig
|
||||
mu.Unlock()
|
||||
var data []byte
|
||||
err := a.xferRequest(ctx, func(ctx context.Context) (*http.Request, error) {
|
||||
return http.NewRequestWithContext(ctx, http.MethodGet, a.xferURL(args.Xfer, i)+"?ack="+strconv.Itoa(ack), nil)
|
||||
}, func(resp *http.Response) error {
|
||||
b, err := io.ReadAll(io.LimitReader(resp.Body, int64(args.Chunk)+1))
|
||||
if err == nil && int64(len(b)) != want {
|
||||
err = errors.New("кусок не той длины")
|
||||
}
|
||||
data = b
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tmp.WriteAt(data, int64(i)*int64(args.Chunk)); err != nil {
|
||||
return err
|
||||
}
|
||||
mu.Lock()
|
||||
got[i] = true
|
||||
for contig < n && got[contig] {
|
||||
contig++
|
||||
}
|
||||
mu.Unlock()
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
var gone errXferGone
|
||||
if errors.As(err, &gone) {
|
||||
return proto.TaskResult{Error: "загрузка прервана: " + string(gone)}
|
||||
}
|
||||
return proto.TaskResult{Error: "загрузка прервана: " + err.Error()}
|
||||
}
|
||||
if err := tmp.Truncate(args.Size); err != nil {
|
||||
return proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
// A file that is being replaced keeps its mode and owner.
|
||||
mode := os.FileMode(0o644)
|
||||
if info, err := os.Stat(args.Path); err == nil {
|
||||
mode = info.Mode().Perm()
|
||||
if st, ok := info.Sys().(*syscall.Stat_t); ok {
|
||||
_ = os.Chown(tmp.Name(), int(st.Uid), int(st.Gid))
|
||||
}
|
||||
}
|
||||
if err := os.Chmod(tmp.Name(), mode); err != nil {
|
||||
return proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
if err := os.Rename(tmp.Name(), args.Path); err != nil {
|
||||
return proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
done = true
|
||||
data, _ := json.Marshal(map[string]any{"path": args.Path, "size": args.Size})
|
||||
return proto.TaskResult{OK: true, Data: data}
|
||||
}
|
||||
|
||||
// doFSSend sends a file to the browser that asked for it.
|
||||
func (a *Agent) doFSSend(ctx context.Context, env proto.TaskEnvelope) proto.TaskResult {
|
||||
var args proto.FSXferArgs
|
||||
if err := json.Unmarshal(env.Args, &args); err != nil {
|
||||
return proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
if args.Chunk <= 0 {
|
||||
return proto.TaskResult{Error: "не задан размер куска"}
|
||||
}
|
||||
f, err := os.Open(args.Path)
|
||||
if err != nil {
|
||||
return proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
defer f.Close()
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
if info.IsDir() {
|
||||
return proto.TaskResult{Error: "это каталог"}
|
||||
}
|
||||
size := info.Size()
|
||||
if size > proto.FSFileCap {
|
||||
return proto.TaskResult{Error: "файл больше 512 МБ"}
|
||||
}
|
||||
sizeHdr := strconv.FormatInt(size, 10)
|
||||
err = pieces(ctx, pieceCount(size, args.Chunk), func(ctx context.Context, i int) error {
|
||||
off := int64(i) * int64(args.Chunk)
|
||||
buf := make([]byte, min(int64(args.Chunk), size-off))
|
||||
if _, err := f.ReadAt(buf, off); err != nil && err != io.EOF {
|
||||
return err
|
||||
}
|
||||
return a.xferRequest(ctx, func(ctx context.Context) (*http.Request, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.xferURL(args.Xfer, i), bytes.NewReader(buf))
|
||||
if err == nil {
|
||||
req.Header.Set("X-Size", sizeHdr)
|
||||
req.Header.Set("Content-Type", "application/octet-stream")
|
||||
}
|
||||
return req, err
|
||||
}, func(*http.Response) error { return nil })
|
||||
})
|
||||
if err != nil {
|
||||
return proto.TaskResult{Error: "передача прервана: " + err.Error()}
|
||||
}
|
||||
data, _ := json.Marshal(map[string]any{"path": args.Path, "size": size})
|
||||
return proto.TaskResult{OK: true, Data: data}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||
)
|
||||
|
||||
// The server can be reached at two addresses: the main one (behind
|
||||
// Cloudflare, say) and a direct one that goes to the server itself. The
|
||||
// direct one comes from the installer or, for agents installed before it was
|
||||
// set, from the server, and is kept in the state file. When the address in
|
||||
// use has been out of reach for a while the agent tries the other; on the
|
||||
// direct one it keeps checking the main one and goes back when it answers.
|
||||
|
||||
const (
|
||||
// switchAfter is how long the address in use may fail before the other
|
||||
// is tried.
|
||||
switchAfter = 30 * time.Second
|
||||
// mainCheck is how often the main address is checked from the direct one.
|
||||
mainCheck = 5 * time.Minute
|
||||
)
|
||||
|
||||
type hostState struct {
|
||||
mu sync.Mutex
|
||||
cur string
|
||||
downSince time.Time
|
||||
changed bool // the address changed: the WebSocket goes there at once
|
||||
}
|
||||
|
||||
// host is the server address in use now.
|
||||
func (a *Agent) host() string {
|
||||
a.hosts.mu.Lock()
|
||||
defer a.hosts.mu.Unlock()
|
||||
if a.hosts.cur == "" {
|
||||
return a.cfg.Host
|
||||
}
|
||||
return a.hosts.cur
|
||||
}
|
||||
|
||||
// fallback is the direct address: the installer's, else the one the server
|
||||
// told.
|
||||
func (a *Agent) fallback() string {
|
||||
if a.cfg.Fallback != "" {
|
||||
return a.cfg.Fallback
|
||||
}
|
||||
a.stateMu.Lock()
|
||||
defer a.stateMu.Unlock()
|
||||
return a.state.Fallback
|
||||
}
|
||||
|
||||
// learnAddrs keeps the server's other address from the two it tells: the
|
||||
// direct one, or the main one when the agent was set to the direct one.
|
||||
func (a *Agent) learnAddrs(main, direct string) {
|
||||
main = strings.TrimRight(strings.TrimSpace(main), "/")
|
||||
direct = strings.TrimRight(strings.TrimSpace(direct), "/")
|
||||
v := direct
|
||||
if direct != "" && strings.EqualFold(direct, a.cfg.Host) {
|
||||
v = main
|
||||
}
|
||||
if v == "" || strings.EqualFold(v, a.cfg.Host) || a.cfg.Fallback != "" {
|
||||
return
|
||||
}
|
||||
a.stateMu.Lock()
|
||||
same := a.state.Fallback == v
|
||||
if !same {
|
||||
a.state.Fallback = v
|
||||
}
|
||||
a.stateMu.Unlock()
|
||||
if !same {
|
||||
log.Printf("fallback address: %s", v)
|
||||
_ = a.saveState()
|
||||
}
|
||||
}
|
||||
|
||||
// setHost moves to addr: requests go there from now on, and the WebSocket
|
||||
// is dropped to reconnect there.
|
||||
func (a *Agent) setHost(addr string) {
|
||||
a.hosts.mu.Lock()
|
||||
if a.hosts.cur == addr || (a.hosts.cur == "" && addr == a.cfg.Host) {
|
||||
a.hosts.mu.Unlock()
|
||||
return
|
||||
}
|
||||
a.hosts.cur, a.hosts.downSince, a.hosts.changed = addr, time.Time{}, true
|
||||
a.hosts.mu.Unlock()
|
||||
log.Printf("server address: %s", addr)
|
||||
if l := a.link.Load(); l != nil {
|
||||
l.close()
|
||||
}
|
||||
}
|
||||
|
||||
// hostChanged reports, once, that the address changed.
|
||||
func (a *Agent) hostChanged() bool {
|
||||
a.hosts.mu.Lock()
|
||||
defer a.hosts.mu.Unlock()
|
||||
c := a.hosts.changed
|
||||
a.hosts.changed = false
|
||||
return c
|
||||
}
|
||||
|
||||
// reached notes the server answered; failed notes it did not, and after
|
||||
// switchAfter of that moves to the other address.
|
||||
func (a *Agent) reached() {
|
||||
a.hosts.mu.Lock()
|
||||
a.hosts.downSince = time.Time{}
|
||||
a.hosts.mu.Unlock()
|
||||
}
|
||||
|
||||
func (a *Agent) failed() {
|
||||
fb := a.fallback()
|
||||
if fb == "" || fb == a.cfg.Host {
|
||||
return
|
||||
}
|
||||
a.hosts.mu.Lock()
|
||||
now := time.Now()
|
||||
if a.hosts.downSince.IsZero() {
|
||||
a.hosts.downSince = now
|
||||
a.hosts.mu.Unlock()
|
||||
return
|
||||
}
|
||||
long := now.Sub(a.hosts.downSince) >= switchAfter
|
||||
cur := a.hosts.cur
|
||||
a.hosts.mu.Unlock()
|
||||
if !long {
|
||||
return
|
||||
}
|
||||
next := fb
|
||||
if cur == fb {
|
||||
next = a.cfg.Host
|
||||
}
|
||||
log.Printf("server out of reach for %s, trying %s", switchAfter, next)
|
||||
a.setHost(next)
|
||||
}
|
||||
|
||||
// watchMain moves back to the main address once it answers again.
|
||||
func (a *Agent) watchMain(ctx context.Context) {
|
||||
t := time.NewTicker(mainCheck)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
}
|
||||
if a.host() == a.cfg.Host {
|
||||
continue
|
||||
}
|
||||
if a.reachableAt(ctx, a.cfg.Host) {
|
||||
log.Printf("main address answers again")
|
||||
a.setHost(a.cfg.Host)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// reachableAt reports whether the server answers a quick request at base.
|
||||
func (a *Agent) reachableAt(ctx context.Context, base string) bool {
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, base+"/api/agent/config", nil)
|
||||
a.authHeaders(req)
|
||||
resp, err := a.http.Do(req)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode < 300 {
|
||||
a.learnAddrs(resp.Header.Get(proto.MainHeader), resp.Header.Get(proto.FallbackHeader))
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -15,14 +15,17 @@ import (
|
||||
)
|
||||
|
||||
// runTerminal spawns a PTY and bridges it to the session.
|
||||
func (a *Agent) runTerminal(parent context.Context, sid string) {
|
||||
term, err := StartPTY()
|
||||
// user is the OS user to run the shell as ("" = the agent's own user).
|
||||
func (a *Agent) runTerminal(parent context.Context, sid, user string) {
|
||||
term, err := StartPTY(user)
|
||||
if err != nil {
|
||||
log.Printf("term %s: start pty: %v", sid, err)
|
||||
// Show the reason in the browser terminal before closing it.
|
||||
a.postOut(context.Background(), sid, proto.OutMessage{Data: b64.EncodeToString([]byte("lpra: " + err.Error() + "\r\n"))})
|
||||
a.postOut(context.Background(), sid, proto.OutMessage{Exit: true})
|
||||
return
|
||||
}
|
||||
log.Printf("term %s: shell started", sid)
|
||||
log.Printf("term %s: shell started (user %q)", sid, user)
|
||||
a.bridgeStream(parent, sid, term, term.Resize)
|
||||
log.Printf("term %s: ended", sid)
|
||||
}
|
||||
@@ -51,6 +54,15 @@ func (a *Agent) bridgeStream(parent context.Context, sid string, rw io.ReadWrite
|
||||
ctx, cancel := context.WithCancel(parent)
|
||||
defer cancel()
|
||||
defer rw.Close()
|
||||
defer func() {
|
||||
if l := a.link.Load(); l != nil {
|
||||
l.unsubscribe(sid)
|
||||
}
|
||||
}()
|
||||
// A session closed from the panel (a tab reloaded, a window shut) ends
|
||||
// the stream at once, not on its next output: an idle shell would live
|
||||
// on until the agent stopped.
|
||||
go func() { <-ctx.Done(); rw.Close() }()
|
||||
|
||||
// Downstream: pull input frames and write them to the stream.
|
||||
go func() {
|
||||
@@ -81,7 +93,22 @@ func (a *Agent) bridgeStream(parent context.Context, sid string, rw io.ReadWrite
|
||||
// pollIn performs one downstream long-poll and applies the frames.
|
||||
// Returns false when the session is gone and the loop should stop.
|
||||
func (a *Agent) pollIn(ctx context.Context, sid string, w io.Writer, onResize func(cols, rows uint16)) bool {
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, a.cfg.Host+"/api/agent/term/"+sid+"/in", nil)
|
||||
// On the WebSocket the input comes pushed; if it drops, by a long poll.
|
||||
if l := a.link.Load(); l != nil {
|
||||
if ch := l.subscribe(sid); ch != nil {
|
||||
select {
|
||||
case in := <-ch:
|
||||
if in.gone {
|
||||
return false
|
||||
}
|
||||
return applyFrames(in.frames, w, onResize)
|
||||
case <-l.done:
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, a.host()+"/api/agent/term/"+sid+"/in", nil)
|
||||
a.authHeaders(req)
|
||||
resp, err := a.http.Do(req)
|
||||
if err != nil {
|
||||
@@ -98,7 +125,13 @@ func (a *Agent) pollIn(ctx context.Context, sid string, w io.Writer, onResize fu
|
||||
if err := json.NewDecoder(resp.Body).Decode(&batch); err != nil {
|
||||
return true
|
||||
}
|
||||
for _, f := range batch.Frames {
|
||||
return applyFrames(batch.Frames, w, onResize)
|
||||
}
|
||||
|
||||
// applyFrames writes a session's input to its stream; false once the panel
|
||||
// closed it.
|
||||
func applyFrames(frames []proto.InFrame, w io.Writer, onResize func(cols, rows uint16)) bool {
|
||||
for _, f := range frames {
|
||||
switch f.Type {
|
||||
case proto.FrameStdin:
|
||||
if data, err := b64.DecodeString(f.Data); err == nil {
|
||||
@@ -117,8 +150,13 @@ func (a *Agent) pollIn(ctx context.Context, sid string, w io.Writer, onResize fu
|
||||
|
||||
// postOut sends one upstream message. Returns false if the session is gone.
|
||||
func (a *Agent) postOut(ctx context.Context, sid string, msg proto.OutMessage) bool {
|
||||
if l := a.link.Load(); l != nil {
|
||||
if l.send(proto.WSMsg{T: "out", SID: sid, Out: &msg}) == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
body, _ := json.Marshal(msg)
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, a.cfg.Host+"/api/agent/term/"+sid+"/out", bytes.NewReader(body))
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, a.host()+"/api/agent/term/"+sid+"/out", bytes.NewReader(body))
|
||||
a.authHeaders(req)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := a.http.Do(req)
|
||||
|
||||
@@ -0,0 +1,580 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
|
||||
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||
)
|
||||
|
||||
// A file or folder goes to another machine as a tar stream: packed here on
|
||||
// the fly, then either posted in pieces to a relay on the server for the
|
||||
// other agent to unpack, or written by SSH straight into tar on that host.
|
||||
// Nothing is staged on disk on either side.
|
||||
|
||||
// shipMeter counts what has gone out, for the panel's progress bar.
|
||||
type shipMeter struct {
|
||||
total, done atomic.Int64
|
||||
files atomic.Int64
|
||||
note atomic.Value // string
|
||||
}
|
||||
|
||||
func (m *shipMeter) snapshot() proto.ShipProgress {
|
||||
n, _ := m.note.Load().(string)
|
||||
return proto.ShipProgress{Total: m.total.Load(), Done: m.done.Load(), Files: int(m.files.Load()), Note: n}
|
||||
}
|
||||
|
||||
// reportShip posts the progress to the job until ctx ends; the server
|
||||
// answering 410 means the job was cancelled, and cancel is called.
|
||||
func (a *Agent) reportShip(ctx context.Context, job string, m *shipMeter, cancel func()) {
|
||||
if job == "" {
|
||||
return
|
||||
}
|
||||
t := time.NewTicker(700 * time.Millisecond)
|
||||
defer t.Stop()
|
||||
for {
|
||||
body, _ := json.Marshal(m.snapshot())
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, a.host()+"/api/agent/fs/job/"+job+"/progress", bytes.NewReader(body))
|
||||
a.authHeaders(req)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if resp, err := a.http.Do(req); err == nil {
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusGone {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// shipName checks the name the thing gets at the other end.
|
||||
func shipName(name string) error {
|
||||
if name == "" || name == "." || name == ".." || strings.ContainsAny(name, "/\x00") {
|
||||
return errors.New("недопустимое имя")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// treeSize is how many bytes of files are under p.
|
||||
func treeSize(ctx context.Context, p string) int64 {
|
||||
var n int64
|
||||
_ = filepath.WalkDir(p, func(_ string, d fs.DirEntry, err error) error {
|
||||
if err != nil || ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
if d.Type().IsRegular() {
|
||||
if info, err := d.Info(); err == nil {
|
||||
n += info.Size()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return n
|
||||
}
|
||||
|
||||
// packTar writes src as a tar stream whose entries start at name. Owners are
|
||||
// left out: the numbers mean other people on another machine.
|
||||
func packTar(ctx context.Context, w io.Writer, src, name string, m *shipMeter) error {
|
||||
tw := tar.NewWriter(w)
|
||||
err := filepath.WalkDir(src, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := os.Lstat(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
link := ""
|
||||
switch {
|
||||
case info.Mode()&fs.ModeSymlink != 0:
|
||||
if link, err = os.Readlink(p); err != nil {
|
||||
return err
|
||||
}
|
||||
case info.IsDir(), info.Mode().IsRegular():
|
||||
default:
|
||||
return nil // sockets, devices, pipes: not sent
|
||||
}
|
||||
hdr, err := tar.FileInfoHeader(info, link)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, _ := filepath.Rel(src, p)
|
||||
hdr.Name = name
|
||||
if rel != "." {
|
||||
hdr.Name = path.Join(name, filepath.ToSlash(rel))
|
||||
}
|
||||
if info.IsDir() {
|
||||
hdr.Name += "/"
|
||||
}
|
||||
hdr.Uid, hdr.Gid, hdr.Uname, hdr.Gname = 0, 0, "", ""
|
||||
hdr.Format = tar.FormatPAX
|
||||
if err := tw.WriteHeader(hdr); err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Mode().IsRegular() {
|
||||
f, err := os.Open(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = io.CopyN(tw, &countReader{r: f, n: &m.done}, hdr.Size)
|
||||
f.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %v", p, err)
|
||||
}
|
||||
}
|
||||
m.files.Add(1)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tw.Close()
|
||||
}
|
||||
|
||||
type countReader struct {
|
||||
r io.Reader
|
||||
n *atomic.Int64
|
||||
}
|
||||
|
||||
func (c *countReader) Read(p []byte) (int, error) {
|
||||
n, err := c.r.Read(p)
|
||||
c.n.Add(int64(n))
|
||||
return n, err
|
||||
}
|
||||
|
||||
// doShipOut sends Src to the other machine: by the relay, or by SSH.
|
||||
func (a *Agent) doShipOut(parent context.Context, env proto.TaskEnvelope) proto.TaskResult {
|
||||
var args proto.FSShipArgs
|
||||
if err := json.Unmarshal(env.Args, &args); err != nil {
|
||||
return proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
if err := shipName(args.Name); err != nil {
|
||||
return proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
src := filepath.Clean(args.Src)
|
||||
if _, err := os.Lstat(src); err != nil {
|
||||
return proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
ctx, cancel := context.WithCancel(parent)
|
||||
defer cancel()
|
||||
m := &shipMeter{}
|
||||
m.note.Store("подсчёт")
|
||||
go a.reportShip(ctx, args.Job, m, cancel)
|
||||
m.total.Store(treeSize(ctx, src))
|
||||
|
||||
var err error
|
||||
if args.SSH != nil {
|
||||
err = a.shipSSH(ctx, args, src, m)
|
||||
} else {
|
||||
m.note.Store("")
|
||||
err = a.shipRelay(ctx, args, src, m)
|
||||
}
|
||||
if err != nil {
|
||||
if ctx.Err() != nil && parent.Err() == nil {
|
||||
err = errors.New("отменено")
|
||||
}
|
||||
return proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
res := map[string]any{"files": m.files.Load(), "bytes": m.done.Load()}
|
||||
if args.Move && args.SSH != nil {
|
||||
if err := os.RemoveAll(src); err != nil {
|
||||
return proto.TaskResult{Error: "отправлено, но здесь не удалилось: " + err.Error()}
|
||||
}
|
||||
}
|
||||
data, _ := json.Marshal(res)
|
||||
return proto.TaskResult{OK: true, Data: data}
|
||||
}
|
||||
|
||||
func (a *Agent) relayURL(id string, i int) string {
|
||||
return a.host() + "/api/agent/fs/relay/" + id + "/" + strconv.Itoa(i)
|
||||
}
|
||||
|
||||
// shipRelay posts the tar stream in pieces, a few at once; the last carries
|
||||
// X-Last.
|
||||
func (a *Agent) shipRelay(ctx context.Context, args proto.FSShipArgs, src string, m *shipMeter) error {
|
||||
if args.Relay == "" || args.Chunk <= 0 {
|
||||
return errors.New("не задана передача")
|
||||
}
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
pr, pw := io.Pipe()
|
||||
go func() { pw.CloseWithError(packTar(ctx, pw, src, args.Name, m)) }()
|
||||
defer pr.Close()
|
||||
|
||||
type piece struct {
|
||||
i int
|
||||
data []byte
|
||||
last bool
|
||||
}
|
||||
ch := make(chan piece, 1)
|
||||
var (
|
||||
mu sync.Mutex
|
||||
first error
|
||||
wg sync.WaitGroup
|
||||
)
|
||||
setErr := func(err error) {
|
||||
mu.Lock()
|
||||
if first == nil {
|
||||
first = err
|
||||
cancel()
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
for w := 0; w < xferWorkers; w++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for p := range ch {
|
||||
err := a.xferRequest(ctx, func(ctx context.Context) (*http.Request, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.relayURL(args.Relay, p.i), bytes.NewReader(p.data))
|
||||
if err == nil {
|
||||
req.Header.Set("Content-Type", "application/octet-stream")
|
||||
if p.last {
|
||||
req.Header.Set("X-Last", "1")
|
||||
}
|
||||
}
|
||||
return req, err
|
||||
}, func(*http.Response) error { return nil })
|
||||
if err != nil {
|
||||
setErr(err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
for i := 0; ; i++ {
|
||||
buf := make([]byte, args.Chunk)
|
||||
n, err := io.ReadFull(pr, buf)
|
||||
last := err == io.EOF || err == io.ErrUnexpectedEOF
|
||||
if err != nil && !last {
|
||||
setErr(err)
|
||||
break
|
||||
}
|
||||
select {
|
||||
case ch <- piece{i, buf[:n], last}:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
if last || ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
close(ch)
|
||||
wg.Wait()
|
||||
if first != nil {
|
||||
var gone errXferGone
|
||||
if errors.As(first, &gone) {
|
||||
return errors.New(string(gone))
|
||||
}
|
||||
return first
|
||||
}
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// doShipIn takes the tar stream from the relay and unpacks it into Dir.
|
||||
func (a *Agent) doShipIn(ctx context.Context, env proto.TaskEnvelope) proto.TaskResult {
|
||||
var args proto.FSShipArgs
|
||||
if err := json.Unmarshal(env.Args, &args); err != nil {
|
||||
return proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
if err := shipName(args.Name); err != nil {
|
||||
return proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
dir := filepath.Clean(args.Dir)
|
||||
if _, err := os.Lstat(filepath.Join(dir, args.Name)); err == nil && args.Conflict != "replace" && args.Conflict != "skip" {
|
||||
return proto.TaskResult{Error: filepath.Join(dir, args.Name) + " уже существует"}
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
pr, pw := io.Pipe()
|
||||
go func() {
|
||||
for i := 0; ; i++ {
|
||||
var data []byte
|
||||
var last bool
|
||||
err := a.xferRequest(ctx, func(ctx context.Context) (*http.Request, error) {
|
||||
return http.NewRequestWithContext(ctx, http.MethodGet, a.relayURL(args.Relay, i), nil)
|
||||
}, func(resp *http.Response) error {
|
||||
b, err := io.ReadAll(io.LimitReader(resp.Body, int64(args.Chunk)+1))
|
||||
data, last = b, resp.Header.Get("X-Last") != ""
|
||||
return err
|
||||
})
|
||||
if err == nil {
|
||||
_, err = pw.Write(data)
|
||||
}
|
||||
if err != nil {
|
||||
pw.CloseWithError(err)
|
||||
return
|
||||
}
|
||||
if last {
|
||||
pw.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
u := &unpacker{dir: dir, name: args.Name, conflict: args.Conflict}
|
||||
if os.Geteuid() == 0 {
|
||||
// New things belong to whoever owns the folder they land in.
|
||||
if info, err := os.Stat(dir); err == nil {
|
||||
if st, ok := info.Sys().(*syscall.Stat_t); ok {
|
||||
u.uid, u.gid, u.chown = int(st.Uid), int(st.Gid), true
|
||||
}
|
||||
}
|
||||
}
|
||||
err := u.unpack(tar.NewReader(pr))
|
||||
if err == nil {
|
||||
// Whatever follows the archive's end is read off, so the sender
|
||||
// finishes.
|
||||
_, err = io.Copy(io.Discard, pr)
|
||||
}
|
||||
pr.CloseWithError(errors.New("распаковка закончена"))
|
||||
if err != nil {
|
||||
var gone errXferGone
|
||||
if errors.As(err, &gone) {
|
||||
err = errors.New(string(gone))
|
||||
}
|
||||
return proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
data, _ := json.Marshal(map[string]int{"done": u.done, "skipped": u.skipped})
|
||||
return proto.TaskResult{OK: true, Data: data}
|
||||
}
|
||||
|
||||
// unpacker writes a tar stream into dir, every entry under name, following
|
||||
// the conflict rule for what is there already: folders merge, and files are
|
||||
// replaced or kept.
|
||||
type unpacker struct {
|
||||
dir, name, conflict string
|
||||
uid, gid int
|
||||
chown bool
|
||||
done, skipped int
|
||||
}
|
||||
|
||||
func (u *unpacker) unpack(tr *tar.Reader) error {
|
||||
var skipUnder string
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
clean := path.Clean(hdr.Name)
|
||||
if clean != u.name && !strings.HasPrefix(clean, u.name+"/") {
|
||||
return fmt.Errorf("в архиве чужой путь %q", hdr.Name)
|
||||
}
|
||||
target := filepath.Join(u.dir, filepath.FromSlash(clean))
|
||||
if skipUnder != "" && strings.HasPrefix(target, skipUnder) {
|
||||
continue
|
||||
}
|
||||
have, err := os.Lstat(target)
|
||||
exists := err == nil
|
||||
if exists && !(hdr.Typeflag == tar.TypeDir && have.IsDir()) {
|
||||
switch u.conflict {
|
||||
case "skip":
|
||||
u.skipped++
|
||||
if hdr.Typeflag == tar.TypeDir {
|
||||
skipUnder = target + string(filepath.Separator)
|
||||
}
|
||||
continue
|
||||
case "replace":
|
||||
if have.IsDir() || hdr.Typeflag != tar.TypeReg {
|
||||
if err := os.RemoveAll(target); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("%s уже существует", target)
|
||||
}
|
||||
}
|
||||
mode := os.FileMode(hdr.Mode).Perm()
|
||||
switch hdr.Typeflag {
|
||||
case tar.TypeDir:
|
||||
if !exists || !have.IsDir() {
|
||||
if err := os.MkdirAll(target, mode|0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = os.Chmod(target, mode)
|
||||
u.own(target)
|
||||
}
|
||||
case tar.TypeReg:
|
||||
if err := u.writeFile(target, tr, mode, hdr.ModTime); err != nil {
|
||||
return err
|
||||
}
|
||||
case tar.TypeSymlink:
|
||||
if err := os.Symlink(hdr.Linkname, target); err != nil {
|
||||
return err
|
||||
}
|
||||
u.own(target)
|
||||
default:
|
||||
continue
|
||||
}
|
||||
u.done++
|
||||
}
|
||||
}
|
||||
|
||||
// writeFile writes next to the target first and puts it in place when all of
|
||||
// it is there.
|
||||
func (u *unpacker) writeFile(target string, r io.Reader, mode os.FileMode, mtime time.Time) error {
|
||||
tmp, err := os.CreateTemp(filepath.Dir(target), "."+filepath.Base(target)+".lpra-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(tmp, r); err != nil {
|
||||
tmp.Close()
|
||||
os.Remove(tmp.Name())
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
os.Remove(tmp.Name())
|
||||
return err
|
||||
}
|
||||
_ = os.Chmod(tmp.Name(), mode)
|
||||
_ = os.Chtimes(tmp.Name(), mtime, mtime)
|
||||
u.own(tmp.Name())
|
||||
if err := os.Rename(tmp.Name(), target); err != nil {
|
||||
os.Remove(tmp.Name())
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *unpacker) own(p string) {
|
||||
if u.chown {
|
||||
_ = os.Lchown(p, u.uid, u.gid)
|
||||
}
|
||||
}
|
||||
|
||||
// shq quotes s for a POSIX shell.
|
||||
func shq(s string) string { return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" }
|
||||
|
||||
// shipSSH writes the tar stream into tar on the SSH host, in Dir.
|
||||
func (a *Agent) shipSSH(ctx context.Context, args proto.FSShipArgs, src string, m *shipMeter) error {
|
||||
client, _, err := a.sshConnect(ctx, *args.SSH, func(_, note string) { m.note.Store(note) })
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
go func() { <-ctx.Done(); client.Close() }()
|
||||
dir := args.Dir
|
||||
if dir == "" {
|
||||
dir = "."
|
||||
}
|
||||
target := strings.TrimSuffix(dir, "/") + "/" + args.Name
|
||||
|
||||
// What tar is there, and whether the target is.
|
||||
m.note.Store("проверка на той стороне")
|
||||
out, err := sshRun(client, "command -v tar >/dev/null 2>&1 || echo LPRA_NOTAR; tar --version 2>/dev/null | head -n 1; test -e "+shq(target)+" -o -L "+shq(target)+" && echo LPRA_EXISTS")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.Contains(out, "LPRA_NOTAR") {
|
||||
return errors.New("на той стороне нет tar")
|
||||
}
|
||||
if strings.Contains(out, "LPRA_EXISTS") && args.Conflict != "replace" && args.Conflict != "skip" {
|
||||
return errors.New(target + " там уже существует")
|
||||
}
|
||||
gnu := strings.Contains(out, "GNU tar")
|
||||
flags := ""
|
||||
switch args.Conflict {
|
||||
case "skip":
|
||||
if gnu {
|
||||
flags = " --skip-old-files"
|
||||
} else {
|
||||
flags = " -k"
|
||||
}
|
||||
case "replace":
|
||||
if gnu {
|
||||
flags = " --overwrite"
|
||||
}
|
||||
}
|
||||
|
||||
sess, err := client.NewSession()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer sess.Close()
|
||||
var stderr bytes.Buffer
|
||||
sess.Stderr = &limitBuf{b: &stderr, max: 4096}
|
||||
stdin, err := sess.StdinPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cmd := "mkdir -p -- " + shq(dir) + " && cd -- " + shq(dir) + " && tar -xf -" + flags
|
||||
if err := sess.Start(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
m.note.Store("")
|
||||
packErr := packTar(ctx, stdin, src, args.Name, m)
|
||||
stdin.Close()
|
||||
waitErr := sess.Wait()
|
||||
msg := strings.TrimSpace(stderr.String())
|
||||
if packErr != nil {
|
||||
if msg != "" {
|
||||
return fmt.Errorf("%v (%s)", packErr, msg)
|
||||
}
|
||||
return packErr
|
||||
}
|
||||
if waitErr != nil {
|
||||
var ee *ssh.ExitError
|
||||
if errors.As(waitErr, &ee) && msg != "" {
|
||||
return errors.New("tar на той стороне: " + msg)
|
||||
}
|
||||
return waitErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sshRun runs a command and returns its output.
|
||||
func sshRun(c *ssh.Client, cmd string) (string, error) {
|
||||
s, err := c.NewSession()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer s.Close()
|
||||
out, err := s.Output(cmd)
|
||||
var ee *ssh.ExitError
|
||||
if errors.As(err, &ee) {
|
||||
err = nil // the checks may exit non-zero; what they print is the answer
|
||||
}
|
||||
return string(out), err
|
||||
}
|
||||
|
||||
type limitBuf struct {
|
||||
b *bytes.Buffer
|
||||
max int
|
||||
}
|
||||
|
||||
func (l *limitBuf) Write(p []byte) (int, error) {
|
||||
if room := l.max - l.b.Len(); room > 0 {
|
||||
l.b.Write(p[:min(len(p), room)])
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||
)
|
||||
|
||||
const (
|
||||
// speedWindow is how long each direction is measured for, unless the
|
||||
// server asks for another window; speedWindowMax bounds what it may ask.
|
||||
speedWindow = 3 * time.Second
|
||||
speedWindowMax = 30 * time.Second
|
||||
// speedWait is how long the download may take to start: in a relayed test
|
||||
// its bytes come from the browser.
|
||||
speedWait = 10 * time.Second
|
||||
// speedUpCap bounds the upload, whatever the link does in the window.
|
||||
speedUpCap = 48 << 20
|
||||
)
|
||||
|
||||
// doSpeed measures the agent's HTTP link to the server: round-trip time of an
|
||||
// empty request, then how much flows each way in the window. In a relayed
|
||||
// test the server pipes both streams to the browser, so they run end to end.
|
||||
func (a *Agent) doSpeed(ctx context.Context, env proto.TaskEnvelope) proto.TaskResult {
|
||||
var args proto.SysSpeedArgs
|
||||
if len(env.Args) > 0 {
|
||||
_ = json.Unmarshal(env.Args, &args)
|
||||
}
|
||||
window := speedWindow
|
||||
if args.WindowMs > 0 {
|
||||
window = min(time.Duration(args.WindowMs)*time.Millisecond, speedWindowMax)
|
||||
}
|
||||
res, err := a.measureSpeed(ctx, args.Relay, window)
|
||||
if err != nil {
|
||||
return proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
data, _ := json.Marshal(res)
|
||||
return proto.TaskResult{OK: true, Data: data}
|
||||
}
|
||||
|
||||
func (a *Agent) measureSpeed(ctx context.Context, relay bool, window time.Duration) (proto.SysSpeedResult, error) {
|
||||
res := proto.SysSpeedResult{Relay: relay}
|
||||
streams := "/api/agent/speed"
|
||||
if relay {
|
||||
streams += "/relay"
|
||||
}
|
||||
// Ping: the first request may pay for a new connection, so keep the best.
|
||||
for i := 0; i < 4; i++ {
|
||||
start := time.Now()
|
||||
resp, err := a.speedRequest(ctx, http.MethodGet, "/api/agent/speed/down?bytes=0", nil)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
ms := float64(time.Since(start).Microseconds()) / 1000
|
||||
if i == 0 || ms < res.PingMs {
|
||||
res.PingMs = ms
|
||||
}
|
||||
}
|
||||
|
||||
down, err := a.speedDown(ctx, streams+"/down", window)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
res.DownBps = down
|
||||
|
||||
// Up: send until the window closes or the cap is hit.
|
||||
body := &speedSource{deadline: time.Now().Add(window), left: speedUpCap}
|
||||
start := time.Now()
|
||||
resp, err := a.speedRequest(ctx, http.MethodPost, streams+"/up", body)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
res.UpBps = bps(int64(speedUpCap-body.left), time.Since(start))
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// speedDown reads what the server streams for the window, counted from the
|
||||
// first byte, and returns the rate.
|
||||
func (a *Agent) speedDown(ctx context.Context, path string, window time.Duration) (float64, error) {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
t := time.AfterFunc(speedWait, cancel)
|
||||
defer t.Stop()
|
||||
resp, err := a.speedRequest(ctx, http.MethodGet, path, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var (
|
||||
n int64
|
||||
start time.Time
|
||||
buf = make([]byte, 64<<10)
|
||||
)
|
||||
for {
|
||||
k, err := resp.Body.Read(buf)
|
||||
if k > 0 && start.IsZero() {
|
||||
start = time.Now()
|
||||
t.Reset(window)
|
||||
}
|
||||
n += int64(k)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if start.IsZero() {
|
||||
return 0, errors.New("нет данных для замера")
|
||||
}
|
||||
return bps(n, time.Since(start)), nil
|
||||
}
|
||||
|
||||
func (a *Agent) speedRequest(ctx context.Context, method, path string, body io.Reader) (*http.Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, method, a.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,512 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||
)
|
||||
|
||||
// SQLite databases are read and changed here, through SQLite itself, so a
|
||||
// database that is in use keeps its locks and journal: a value is set with an
|
||||
// UPDATE, not by writing the file over.
|
||||
|
||||
// dbCell is one value as the panel shows and sends it: t is "i" (integer),
|
||||
// "r" (real), "s" (text), "b" (blob, v its size, not editable) or "n" (NULL).
|
||||
type dbCell struct {
|
||||
T string `json:"t"`
|
||||
V string `json:"v,omitempty"`
|
||||
}
|
||||
|
||||
func cellOf(v any) dbCell {
|
||||
switch x := v.(type) {
|
||||
case nil:
|
||||
return dbCell{T: "n"}
|
||||
case int64:
|
||||
return dbCell{T: "i", V: strconv.FormatInt(x, 10)}
|
||||
case float64:
|
||||
return dbCell{T: "r", V: strconv.FormatFloat(x, 'g', -1, 64)}
|
||||
case string:
|
||||
return dbCell{T: "s", V: x}
|
||||
case []byte:
|
||||
return dbCell{T: "b", V: strconv.Itoa(len(x))}
|
||||
case bool:
|
||||
if x {
|
||||
return dbCell{T: "i", V: "1"}
|
||||
}
|
||||
return dbCell{T: "i", V: "0"}
|
||||
case time.Time:
|
||||
return dbCell{T: "s", V: x.Format(time.RFC3339Nano)}
|
||||
}
|
||||
return dbCell{T: "s", V: fmt.Sprint(v)}
|
||||
}
|
||||
|
||||
// value is what a cell becomes for SQLite.
|
||||
func (c dbCell) value() (any, error) {
|
||||
switch c.T {
|
||||
case "n":
|
||||
return nil, nil
|
||||
case "i":
|
||||
n, err := strconv.ParseInt(strings.TrimSpace(c.V), 10, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%q - не целое число", c.V)
|
||||
}
|
||||
return n, nil
|
||||
case "r":
|
||||
f, err := strconv.ParseFloat(strings.TrimSpace(c.V), 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%q - не число", c.V)
|
||||
}
|
||||
return f, nil
|
||||
case "x":
|
||||
b, err := hex.DecodeString(strings.ReplaceAll(c.V, " ", ""))
|
||||
if err != nil {
|
||||
return nil, errors.New("blob - нужны шестнадцатеричные байты")
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
return c.V, nil
|
||||
}
|
||||
|
||||
type dbArgs struct {
|
||||
Path string `json:"path"`
|
||||
Table string `json:"table,omitempty"`
|
||||
Offset int `json:"offset,omitempty"`
|
||||
Limit int `json:"limit,omitempty"`
|
||||
Key json.RawMessage `json:"key,omitempty"`
|
||||
Keys []json.RawMessage `json:"keys,omitempty"`
|
||||
Column string `json:"column,omitempty"`
|
||||
Value *dbCell `json:"value,omitempty"`
|
||||
Values map[string]dbCell `json:"values,omitempty"`
|
||||
SQL string `json:"sql,omitempty"`
|
||||
}
|
||||
|
||||
func dbOpen(path string) (*sql.DB, error) {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil, errors.New("это каталог")
|
||||
}
|
||||
u := url.URL{Scheme: "file", Path: path}
|
||||
db, err := sql.Open("sqlite", u.String()+"?mode=rw&_pragma=busy_timeout(5000)")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func qid(name string) string { return `"` + strings.ReplaceAll(name, `"`, `""`) + `"` }
|
||||
|
||||
// dbTable is a table's shape: its columns, and how a row is found again -
|
||||
// by rowid, by its primary key (a WITHOUT ROWID table), or not at all (a
|
||||
// view: shown, not changed).
|
||||
type dbTable struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Columns []dbColumn `json:"columns"`
|
||||
Key string `json:"key"` // "rowid", "pk" or ""
|
||||
// Alias is the INTEGER PRIMARY KEY column: another name for the rowid.
|
||||
Alias string `json:"alias,omitempty"`
|
||||
pk []string
|
||||
}
|
||||
|
||||
type dbColumn struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
NotNull bool `json:"notnull,omitempty"`
|
||||
PK bool `json:"pk,omitempty"`
|
||||
Default string `json:"default,omitempty"`
|
||||
}
|
||||
|
||||
func dbTableInfo(ctx context.Context, db *sql.DB, name string) (*dbTable, error) {
|
||||
t := &dbTable{Name: name}
|
||||
if err := db.QueryRowContext(ctx, `SELECT type FROM sqlite_master WHERE name = ? AND type IN ('table','view')`, name).Scan(&t.Type); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, fmt.Errorf("нет таблицы %s", name)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
rows, err := db.QueryContext(ctx, `SELECT name, type, "notnull", dflt_value, pk FROM pragma_table_info(?)`, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
type pkc struct {
|
||||
n int
|
||||
col string
|
||||
}
|
||||
var pks []pkc
|
||||
for rows.Next() {
|
||||
var c dbColumn
|
||||
var notnull, pk int
|
||||
var dflt sql.NullString
|
||||
if err := rows.Scan(&c.Name, &c.Type, ¬null, &dflt, &pk); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.NotNull, c.PK, c.Default = notnull == 1, pk > 0, dflt.String
|
||||
if pk > 0 {
|
||||
pks = append(pks, pkc{pk, c.Name})
|
||||
}
|
||||
t.Columns = append(t.Columns, c)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := 1; i <= len(pks); i++ {
|
||||
for _, p := range pks {
|
||||
if p.n == i {
|
||||
t.pk = append(t.pk, p.col)
|
||||
}
|
||||
}
|
||||
}
|
||||
if t.Type == "table" {
|
||||
if r, err := db.QueryContext(ctx, `SELECT rowid FROM `+qid(name)+` LIMIT 0`); err == nil {
|
||||
r.Close()
|
||||
t.Key = "rowid"
|
||||
if len(t.pk) == 1 {
|
||||
for _, c := range t.Columns {
|
||||
if c.Name == t.pk[0] && strings.EqualFold(strings.TrimSpace(c.Type), "INTEGER") {
|
||||
t.Alias = c.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if len(t.pk) > 0 {
|
||||
t.Key = "pk"
|
||||
}
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// where is the condition that finds the row by its key: a rowid, or the
|
||||
// primary key's cells in order.
|
||||
func (t *dbTable) where(key json.RawMessage) (string, []any, error) {
|
||||
switch t.Key {
|
||||
case "rowid":
|
||||
var id int64
|
||||
if err := json.Unmarshal(key, &id); err != nil {
|
||||
var s string
|
||||
if json.Unmarshal(key, &s) != nil {
|
||||
return "", nil, errors.New("плохой ключ строки")
|
||||
}
|
||||
if id, err = strconv.ParseInt(s, 10, 64); err != nil {
|
||||
return "", nil, errors.New("плохой ключ строки")
|
||||
}
|
||||
}
|
||||
return "rowid = ?", []any{id}, nil
|
||||
case "pk":
|
||||
var cells []dbCell
|
||||
if err := json.Unmarshal(key, &cells); err != nil || len(cells) != len(t.pk) {
|
||||
return "", nil, errors.New("плохой ключ строки")
|
||||
}
|
||||
parts, args := make([]string, len(t.pk)), make([]any, len(t.pk))
|
||||
for i, col := range t.pk {
|
||||
v, err := cells[i].value()
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
parts[i], args[i] = qid(col)+" IS ?", v
|
||||
}
|
||||
return strings.Join(parts, " AND "), args, nil
|
||||
}
|
||||
return "", nil, errors.New("строки этой таблицы нельзя менять: нет ни rowid, ни первичного ключа")
|
||||
}
|
||||
|
||||
func (t *dbTable) column(name string) bool {
|
||||
for _, c := range t.Columns {
|
||||
if c.Name == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// dbTask runs one database task.
|
||||
func dbTask(env proto.TaskEnvelope) (any, error) {
|
||||
var a dbArgs
|
||||
if err := json.Unmarshal(env.Args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db, err := dbOpen(a.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer db.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if env.Type == proto.TaskDBTables {
|
||||
rows, err := db.QueryContext(ctx, `SELECT name, type FROM sqlite_master WHERE type IN ('table','view') AND name NOT LIKE 'sqlite_%' ORDER BY type, name`)
|
||||
if err != nil {
|
||||
return nil, dbErr(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
type item struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
out := []item{}
|
||||
for rows.Next() {
|
||||
var it item
|
||||
if err := rows.Scan(&it.Name, &it.Type); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, it)
|
||||
}
|
||||
return map[string]any{"tables": out}, rows.Err()
|
||||
}
|
||||
if env.Type == proto.TaskDBSQL {
|
||||
return dbSQL(ctx, db, a.SQL)
|
||||
}
|
||||
|
||||
t, err := dbTableInfo(ctx, db, a.Table)
|
||||
if err != nil {
|
||||
return nil, dbErr(err)
|
||||
}
|
||||
switch env.Type {
|
||||
case proto.TaskDBRows:
|
||||
return dbRows(ctx, db, t, a.Offset, a.Limit)
|
||||
case proto.TaskDBSet:
|
||||
if !t.column(a.Column) || a.Value == nil {
|
||||
return nil, errors.New("нет такого столбца")
|
||||
}
|
||||
cond, args, err := t.where(a.Key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v, err := a.Value.value()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res, err := db.ExecContext(ctx, `UPDATE `+qid(t.Name)+` SET `+qid(a.Column)+` = ? WHERE `+cond, append([]any{v}, args...)...)
|
||||
if err != nil {
|
||||
return nil, dbErr(err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n != 1 {
|
||||
return nil, fmt.Errorf("изменено строк: %d - строку могли удалить или поменять", n)
|
||||
}
|
||||
// A key column changed: the row is found by its new key now.
|
||||
out := map[string]any{}
|
||||
switch {
|
||||
case t.Key == "rowid" && a.Column == t.Alias:
|
||||
args = []any{v}
|
||||
out["key"] = a.Value.V
|
||||
case t.Key == "pk":
|
||||
var cells []dbCell
|
||||
_ = json.Unmarshal(a.Key, &cells)
|
||||
for i, col := range t.pk {
|
||||
if col == a.Column {
|
||||
args[i], cells[i] = v, *a.Value
|
||||
out["key"] = cells
|
||||
}
|
||||
}
|
||||
}
|
||||
// What SQLite stored, after its type affinity.
|
||||
var got any
|
||||
_ = db.QueryRowContext(ctx, `SELECT `+qid(a.Column)+` FROM `+qid(t.Name)+` WHERE `+cond, args...).Scan(&got)
|
||||
out["value"] = cellOf(got)
|
||||
if k, ok := out["key"].([]dbCell); ok {
|
||||
for i, col := range t.pk {
|
||||
if col == a.Column {
|
||||
k[i] = cellOf(got)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
case proto.TaskDBAdd:
|
||||
if t.Type != "table" {
|
||||
return nil, errors.New("в представление строки не добавляются")
|
||||
}
|
||||
var cols, marks []string
|
||||
var vals []any
|
||||
for name, c := range a.Values {
|
||||
if !t.column(name) {
|
||||
return nil, fmt.Errorf("нет столбца %s", name)
|
||||
}
|
||||
v, err := c.value()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cols, marks, vals = append(cols, qid(name)), append(marks, "?"), append(vals, v)
|
||||
}
|
||||
q := `INSERT INTO ` + qid(t.Name) + ` DEFAULT VALUES`
|
||||
if len(cols) > 0 {
|
||||
q = `INSERT INTO ` + qid(t.Name) + ` (` + strings.Join(cols, ", ") + `) VALUES (` + strings.Join(marks, ", ") + `)`
|
||||
}
|
||||
res, err := db.ExecContext(ctx, q, vals...)
|
||||
if err != nil {
|
||||
return nil, dbErr(err)
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return map[string]any{"rowid": id}, nil
|
||||
case proto.TaskDBDel:
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, dbErr(err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
n := int64(0)
|
||||
for _, k := range a.Keys {
|
||||
cond, args, err := t.where(k)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res, err := tx.ExecContext(ctx, `DELETE FROM `+qid(t.Name)+` WHERE `+cond, args...)
|
||||
if err != nil {
|
||||
return nil, dbErr(err)
|
||||
}
|
||||
c, _ := res.RowsAffected()
|
||||
n += c
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, dbErr(err)
|
||||
}
|
||||
return map[string]any{"deleted": n}, nil
|
||||
}
|
||||
return nil, errors.New("неизвестная операция")
|
||||
}
|
||||
|
||||
// dbRows is a page of a table's rows, with each row's key.
|
||||
func dbRows(ctx context.Context, db *sql.DB, t *dbTable, offset, limit int) (any, error) {
|
||||
if limit <= 0 || limit > 1000 {
|
||||
limit = 200
|
||||
}
|
||||
var total int64
|
||||
_ = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM `+qid(t.Name)).Scan(&total)
|
||||
sel := make([]string, 0, len(t.Columns)+1)
|
||||
if t.Key == "rowid" {
|
||||
sel = append(sel, "rowid")
|
||||
}
|
||||
for _, c := range t.Columns {
|
||||
sel = append(sel, qid(c.Name))
|
||||
}
|
||||
order := ""
|
||||
switch t.Key {
|
||||
case "rowid":
|
||||
order = " ORDER BY rowid"
|
||||
case "pk":
|
||||
parts := make([]string, len(t.pk))
|
||||
for i, c := range t.pk {
|
||||
parts[i] = qid(c)
|
||||
}
|
||||
order = " ORDER BY " + strings.Join(parts, ", ")
|
||||
}
|
||||
rows, err := db.QueryContext(ctx, `SELECT `+strings.Join(sel, ", ")+` FROM `+qid(t.Name)+order+` LIMIT ? OFFSET ?`, limit, offset)
|
||||
if err != nil {
|
||||
return nil, dbErr(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
type row struct {
|
||||
K any `json:"k,omitempty"`
|
||||
V []dbCell `json:"v"`
|
||||
}
|
||||
out := []row{}
|
||||
n := len(sel)
|
||||
for rows.Next() {
|
||||
vals := make([]any, n)
|
||||
ptrs := make([]any, n)
|
||||
for i := range vals {
|
||||
ptrs[i] = &vals[i]
|
||||
}
|
||||
if err := rows.Scan(ptrs...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r := row{}
|
||||
if t.Key == "rowid" {
|
||||
r.K = cellOf(vals[0]).V
|
||||
vals = vals[1:]
|
||||
}
|
||||
for _, v := range vals {
|
||||
r.V = append(r.V, cellOf(v))
|
||||
}
|
||||
if t.Key == "pk" {
|
||||
key := make([]dbCell, len(t.pk))
|
||||
for i, pk := range t.pk {
|
||||
for j, c := range t.Columns {
|
||||
if c.Name == pk {
|
||||
key[i] = r.V[j]
|
||||
}
|
||||
}
|
||||
}
|
||||
r.K = key
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return map[string]any{"table": t, "total": total, "offset": offset, "rows": out}, rows.Err()
|
||||
}
|
||||
|
||||
// dbSQL runs what was typed: a query gives up to 500 rows, anything else
|
||||
// how many rows it changed.
|
||||
func dbSQL(ctx context.Context, db *sql.DB, q string) (any, error) {
|
||||
q = strings.TrimSpace(q)
|
||||
if q == "" {
|
||||
return nil, errors.New("пустой запрос")
|
||||
}
|
||||
head := strings.ToLower(strings.Fields(q)[0])
|
||||
if head == "select" || head == "pragma" || head == "with" || head == "explain" || head == "values" {
|
||||
rows, err := db.QueryContext(ctx, q)
|
||||
if err != nil {
|
||||
return nil, dbErr(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
cols, _ := rows.Columns()
|
||||
out := [][]dbCell{}
|
||||
more := false
|
||||
for rows.Next() {
|
||||
if len(out) == 500 {
|
||||
more = true
|
||||
break
|
||||
}
|
||||
vals := make([]any, len(cols))
|
||||
ptrs := make([]any, len(cols))
|
||||
for i := range vals {
|
||||
ptrs[i] = &vals[i]
|
||||
}
|
||||
if err := rows.Scan(ptrs...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r := make([]dbCell, len(cols))
|
||||
for i, v := range vals {
|
||||
r[i] = cellOf(v)
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return map[string]any{"columns": cols, "rows": out, "more": more}, dbErr(rows.Err())
|
||||
}
|
||||
res, err := db.ExecContext(ctx, q)
|
||||
if err != nil {
|
||||
return nil, dbErr(err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return map[string]any{"changed": n}, nil
|
||||
}
|
||||
|
||||
// dbErr says the common SQLite troubles in words.
|
||||
func dbErr(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
m := err.Error()
|
||||
switch {
|
||||
case strings.Contains(m, "file is not a database"):
|
||||
return errors.New("это не база SQLite")
|
||||
case strings.Contains(m, "database is locked"), strings.Contains(m, "SQLITE_BUSY"):
|
||||
return errors.New("база занята другой программой - попробуйте ещё раз")
|
||||
case strings.Contains(m, "readonly"):
|
||||
return errors.New("база только для чтения (нет прав на файл или его папку)")
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
|
||||
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||
)
|
||||
|
||||
// An SSH terminal: the agent connects to another host by SSH and bridges the
|
||||
// remote shell to the session, as it does a local one. The panel is told how
|
||||
// the connection goes (connecting, signing in, in) to show it.
|
||||
|
||||
// sshKnownHosts holds the host keys seen, next to the agent's state: a host
|
||||
// is trusted the first time, and a changed key is refused.
|
||||
func (a *Agent) sshKnownHosts() string {
|
||||
return filepath.Join(filepath.Dir(a.cfg.StatePath), "ssh_known_hosts")
|
||||
}
|
||||
|
||||
var knownHostsMu sync.Mutex
|
||||
|
||||
// hostKeyCheck trusts a new host and remembers its key; a host whose key
|
||||
// has changed since is refused, as ssh would.
|
||||
func (a *Agent) hostKeyCheck(seen *string) ssh.HostKeyCallback {
|
||||
return func(hostname string, _ net.Addr, key ssh.PublicKey) error {
|
||||
knownHostsMu.Lock()
|
||||
defer knownHostsMu.Unlock()
|
||||
fp := ssh.FingerprintSHA256(key)
|
||||
*seen = fp
|
||||
path := a.sshKnownHosts()
|
||||
line := hostname + " " + key.Type() + " "
|
||||
if f, err := os.Open(path); err == nil {
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
fields := strings.Fields(sc.Text())
|
||||
if len(fields) != 3 || fields[0] != hostname {
|
||||
continue
|
||||
}
|
||||
if fields[1] == key.Type() && fields[2] == fp {
|
||||
f.Close()
|
||||
return nil
|
||||
}
|
||||
if fields[1] == key.Type() {
|
||||
f.Close()
|
||||
return fmt.Errorf("ключ хоста %s изменился: был %s, теперь %s. Если так и должно быть, удалите строку с этим хостом из %s на агенте",
|
||||
hostname, fields[2], fp, path)
|
||||
}
|
||||
}
|
||||
f.Close()
|
||||
}
|
||||
_ = os.MkdirAll(filepath.Dir(path), 0o700)
|
||||
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
|
||||
if err == nil {
|
||||
_, _ = f.WriteString(line + fp + "\n")
|
||||
f.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// sshAuth is how to sign in: the key given, the password given (also
|
||||
// answered to keyboard-interactive prompts), or the agent user's own keys.
|
||||
func sshAuth(t proto.SSHTarget) ([]ssh.AuthMethod, error) {
|
||||
var methods []ssh.AuthMethod
|
||||
if t.Key != "" {
|
||||
var signer ssh.Signer
|
||||
var err error
|
||||
if t.Passphrase != "" {
|
||||
signer, err = ssh.ParsePrivateKeyWithPassphrase([]byte(t.Key), []byte(t.Passphrase))
|
||||
} else {
|
||||
signer, err = ssh.ParsePrivateKey([]byte(t.Key))
|
||||
}
|
||||
var missing *ssh.PassphraseMissingError
|
||||
if errors.As(err, &missing) {
|
||||
return nil, errors.New("ключ защищён фразой - введите её")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ключ не читается: %v", err)
|
||||
}
|
||||
methods = append(methods, ssh.PublicKeys(signer))
|
||||
}
|
||||
if t.Password != "" {
|
||||
pw := t.Password
|
||||
methods = append(methods, ssh.Password(pw),
|
||||
ssh.KeyboardInteractive(func(_, _ string, qs []string, _ []bool) ([]string, error) {
|
||||
ans := make([]string, len(qs))
|
||||
for i := range ans {
|
||||
ans[i] = pw
|
||||
}
|
||||
return ans, nil
|
||||
}))
|
||||
}
|
||||
if len(methods) == 0 {
|
||||
var signers []ssh.Signer
|
||||
for _, n := range []string{"id_ed25519", "id_ecdsa", "id_rsa"} {
|
||||
b, err := os.ReadFile(filepath.Join(homeDir(""), ".ssh", n))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if s, err := ssh.ParsePrivateKey(b); err == nil {
|
||||
signers = append(signers, s)
|
||||
}
|
||||
}
|
||||
if len(signers) == 0 {
|
||||
return nil, errors.New("не задан ни пароль, ни ключ, и в ~/.ssh агента нет ключей без фразы")
|
||||
}
|
||||
methods = append(methods, ssh.PublicKeys(signers...))
|
||||
}
|
||||
return methods, nil
|
||||
}
|
||||
|
||||
// sshShell is the remote shell as a stream: its output (stdout and stderr,
|
||||
// one on a PTY) to read, keystrokes to write.
|
||||
type sshShell struct {
|
||||
client *ssh.Client
|
||||
sess *ssh.Session
|
||||
stdin io.WriteCloser
|
||||
out *io.PipeReader
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (s *sshShell) Read(b []byte) (int, error) { return s.out.Read(b) }
|
||||
func (s *sshShell) Write(b []byte) (int, error) { return s.stdin.Write(b) }
|
||||
func (s *sshShell) Resize(cols, rows uint16) {
|
||||
if cols > 0 && rows > 0 {
|
||||
_ = s.sess.WindowChange(int(rows), int(cols))
|
||||
}
|
||||
}
|
||||
func (s *sshShell) Close() error {
|
||||
s.once.Do(func() {
|
||||
_ = s.sess.Close()
|
||||
_ = s.client.Close()
|
||||
_ = s.out.Close()
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// sshConnect connects and signs in to t, saying how it goes by status (the
|
||||
// SSHStatus steps). It returns the client and the host key's fingerprint.
|
||||
func (a *Agent) sshConnect(ctx context.Context, t proto.SSHTarget, status func(st, note string)) (*ssh.Client, string, error) {
|
||||
if t.Port == 0 {
|
||||
t.Port = 22
|
||||
}
|
||||
addr := net.JoinHostPort(t.Host, strconv.Itoa(t.Port))
|
||||
auth, err := sshAuth(t)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
status(proto.SSHDial, "подключение к "+addr)
|
||||
d := net.Dialer{Timeout: 15 * time.Second, KeepAlive: 30 * time.Second}
|
||||
conn, err := d.DialContext(ctx, "tcp", addr)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("не подключиться к %s: %v", addr, err)
|
||||
}
|
||||
status(proto.SSHAuth, "вход как "+t.User)
|
||||
var fp string
|
||||
_ = conn.SetDeadline(time.Now().Add(30 * time.Second))
|
||||
c, chans, reqs, err := ssh.NewClientConn(conn, addr, &ssh.ClientConfig{
|
||||
User: t.User,
|
||||
Auth: auth,
|
||||
HostKeyCallback: a.hostKeyCheck(&fp),
|
||||
Timeout: 15 * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
msg := strings.TrimPrefix(err.Error(), "ssh: handshake failed: ")
|
||||
if strings.Contains(msg, "unable to authenticate") {
|
||||
msg = "сервер не принял вход как " + t.User + " - проверьте пароль или ключ"
|
||||
}
|
||||
return nil, "", errors.New(msg)
|
||||
}
|
||||
_ = conn.SetDeadline(time.Time{})
|
||||
return ssh.NewClient(c, chans, reqs), fp, nil
|
||||
}
|
||||
|
||||
// runSSH connects to the target and bridges its shell to the session.
|
||||
func (a *Agent) runSSH(parent context.Context, sid string, t proto.SSHTarget) {
|
||||
status := func(st, note string) {
|
||||
a.postOut(context.Background(), sid, proto.OutMessage{Status: st, Note: note})
|
||||
}
|
||||
fail := func(err error) {
|
||||
log.Printf("ssh %s: %v", sid, err)
|
||||
status(proto.SSHErr, err.Error())
|
||||
a.postOut(context.Background(), sid, proto.OutMessage{Data: b64.EncodeToString([]byte("\x1b[31m" + err.Error() + "\x1b[0m\r\n"))})
|
||||
a.postOut(context.Background(), sid, proto.OutMessage{Exit: true})
|
||||
}
|
||||
client, fp, err := a.sshConnect(parent, t, status)
|
||||
if err != nil {
|
||||
fail(err)
|
||||
return
|
||||
}
|
||||
port := t.Port
|
||||
if port == 0 {
|
||||
port = 22
|
||||
}
|
||||
addr := net.JoinHostPort(t.Host, strconv.Itoa(port))
|
||||
sess, err := client.NewSession()
|
||||
if err != nil {
|
||||
client.Close()
|
||||
fail(fmt.Errorf("сессия не открылась: %v", err))
|
||||
return
|
||||
}
|
||||
modes := ssh.TerminalModes{ssh.ECHO: 1, ssh.TTY_OP_ISPEED: 115200, ssh.TTY_OP_OSPEED: 115200}
|
||||
if err := sess.RequestPty("xterm-256color", 24, 80, modes); err != nil {
|
||||
sess.Close()
|
||||
client.Close()
|
||||
fail(fmt.Errorf("терминал не выделен: %v", err))
|
||||
return
|
||||
}
|
||||
stdin, _ := sess.StdinPipe()
|
||||
pr, pw := io.Pipe()
|
||||
sess.Stdout = pw
|
||||
sess.Stderr = pw
|
||||
if err := sess.Shell(); err != nil {
|
||||
sess.Close()
|
||||
client.Close()
|
||||
fail(fmt.Errorf("оболочка не запустилась: %v", err))
|
||||
return
|
||||
}
|
||||
sh := &sshShell{client: client, sess: sess, stdin: stdin, out: pr}
|
||||
go func() { _ = sess.Wait(); pw.Close() }()
|
||||
// Keep-alives, so a dead host is noticed and the session ends.
|
||||
go func() {
|
||||
tk := time.NewTicker(30 * time.Second)
|
||||
defer tk.Stop()
|
||||
for range tk.C {
|
||||
if _, _, err := client.SendRequest("keepalive@openssh.com", true, nil); err != nil {
|
||||
sh.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
log.Printf("ssh %s: %s@%s up (host key %s)", sid, t.User, addr, fp)
|
||||
status(proto.SSHOK, t.User+"@"+addr)
|
||||
a.bridgeStream(parent, sid, sh, sh.Resize)
|
||||
log.Printf("ssh %s: ended", sid)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+18
-6
@@ -17,9 +17,20 @@ func (a *Agent) runTask(ctx context.Context, taskID string) {
|
||||
return
|
||||
}
|
||||
var res proto.TaskResult
|
||||
if env.Type == proto.TaskSysUpdate {
|
||||
switch env.Type {
|
||||
case proto.TaskSysUpdate:
|
||||
res = a.doUpdate()
|
||||
} else {
|
||||
case proto.TaskSysSpeed:
|
||||
res = a.doSpeed(ctx, env)
|
||||
case proto.TaskFSPut:
|
||||
res = a.doFSPut(ctx, env)
|
||||
case proto.TaskFSSend:
|
||||
res = a.doFSSend(ctx, env)
|
||||
case proto.TaskFSShipOut:
|
||||
res = a.doShipOut(ctx, env)
|
||||
case proto.TaskFSShipIn:
|
||||
res = a.doShipIn(ctx, env)
|
||||
default:
|
||||
res = a.execTask(env)
|
||||
}
|
||||
a.postResult(ctx, taskID, res)
|
||||
@@ -27,7 +38,7 @@ func (a *Agent) runTask(ctx context.Context, taskID string) {
|
||||
|
||||
func (a *Agent) fetchTask(ctx context.Context, taskID string) (proto.TaskEnvelope, error) {
|
||||
var env proto.TaskEnvelope
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, a.cfg.Host+"/api/agent/task/"+taskID, nil)
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, a.host()+"/api/agent/task/"+taskID, nil)
|
||||
a.authHeaders(req)
|
||||
resp, err := a.http.Do(req)
|
||||
if err != nil {
|
||||
@@ -43,7 +54,7 @@ func (a *Agent) fetchTask(ctx context.Context, taskID string) (proto.TaskEnvelop
|
||||
|
||||
func (a *Agent) postResult(ctx context.Context, taskID string, res proto.TaskResult) {
|
||||
body, _ := json.Marshal(res)
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, a.cfg.Host+"/api/agent/task/"+taskID+"/result", bytes.NewReader(body))
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, a.host()+"/api/agent/task/"+taskID+"/result", bytes.NewReader(body))
|
||||
a.authHeaders(req)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := a.http.Do(req)
|
||||
@@ -54,9 +65,10 @@ func (a *Agent) postResult(ctx context.Context, taskID string, res proto.TaskRes
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
// execTask runs a single file-manager operation natively and returns a result.
|
||||
// execTask runs a single file-manager or user-management operation natively
|
||||
// and returns a result.
|
||||
func (a *Agent) execTask(env proto.TaskEnvelope) proto.TaskResult {
|
||||
data, err := dispatchFS(env)
|
||||
data, err := dispatchTask(env)
|
||||
if err != nil {
|
||||
return proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
|
||||
+91
-17
@@ -1,32 +1,94 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/creack/pty"
|
||||
)
|
||||
|
||||
// PTY is a pseudo-terminal running an interactive login shell.
|
||||
type PTY struct {
|
||||
cmd *exec.Cmd
|
||||
f *os.File
|
||||
cmd *exec.Cmd
|
||||
f *os.File
|
||||
exited chan struct{} // closed once the shell has exited
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
// StartPTY launches the shell attached to a new PTY. The agent runs as root
|
||||
// (systemd User=root), so this is a root shell by design.
|
||||
func StartPTY() (*PTY, error) {
|
||||
shell := "/bin/bash"
|
||||
if _, err := os.Stat(shell); err != nil {
|
||||
shell = "/bin/sh"
|
||||
// hangupGrace is how long a shell gets after the hangup to save its history
|
||||
// and exit before it is killed.
|
||||
const hangupGrace = 3 * time.Second
|
||||
|
||||
// openPTYs are the terminals running now, to close on shutdown.
|
||||
var openPTYs sync.Map // *PTY -> struct{}
|
||||
|
||||
// CloseTerminals hangs up every open terminal and waits for them, so the
|
||||
// shells save their history before the agent exits.
|
||||
func CloseTerminals() {
|
||||
var wg sync.WaitGroup
|
||||
openPTYs.Range(func(k, _ any) bool {
|
||||
wg.Add(1)
|
||||
go func() { defer wg.Done(); k.(*PTY).Close() }()
|
||||
return true
|
||||
})
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// StartPTY launches a login shell attached to a new PTY. With user empty (or
|
||||
// equal to the agent's own user) it is the agent's shell - root by default,
|
||||
// since the agent runs as a root systemd service. Any other user is entered via
|
||||
// `su -l`, which only a root agent can do without a password.
|
||||
func StartPTY(user string) (*PTY, error) {
|
||||
var cmd *exec.Cmd
|
||||
env := append(os.Environ(), "TERM=xterm-256color")
|
||||
if user == "" || user == currentUser() {
|
||||
cmd = exec.Command(defaultShell(), "-l")
|
||||
// A login shell does not change directory by itself: it would start
|
||||
// where the service runs, "/". Start it at home, and say where home
|
||||
// is, as a root service often has no $HOME.
|
||||
if home := homeDir(""); isDir(home) {
|
||||
cmd.Dir = home
|
||||
env = append(env, "HOME="+home)
|
||||
}
|
||||
} 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)
|
||||
// su -l goes to the user's home; starting there too keeps the shell
|
||||
// out of "/" should it not.
|
||||
if home := homeDir(user); isDir(home) {
|
||||
cmd.Dir = home
|
||||
}
|
||||
}
|
||||
cmd := exec.Command(shell, "-l")
|
||||
cmd.Env = append(os.Environ(), "TERM=xterm-256color")
|
||||
cmd.Env = env
|
||||
f, err := pty.Start(cmd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &PTY{cmd: cmd, f: f}, nil
|
||||
p := &PTY{cmd: cmd, f: f, exited: make(chan struct{})}
|
||||
go func() { _ = cmd.Wait(); close(p.exited) }()
|
||||
openPTYs.Store(p, struct{}{})
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func isDir(p string) bool {
|
||||
info, err := os.Stat(p)
|
||||
return err == nil && info.IsDir()
|
||||
}
|
||||
|
||||
// Read reads terminal output.
|
||||
@@ -43,12 +105,24 @@ func (p *PTY) Resize(cols, rows uint16) {
|
||||
_ = pty.Setsize(p.f, &pty.Winsize{Rows: rows, Cols: cols})
|
||||
}
|
||||
|
||||
// Close terminates the shell and releases the PTY.
|
||||
// Close hangs the terminal up as a closed window would: the shell gets
|
||||
// SIGHUP, saves its history and exits. One that does not within hangupGrace
|
||||
// is killed. Safe to call more than once.
|
||||
func (p *PTY) Close() error {
|
||||
err := p.f.Close()
|
||||
if p.cmd.Process != nil {
|
||||
_ = p.cmd.Process.Kill()
|
||||
}
|
||||
_ = p.cmd.Wait()
|
||||
var err error
|
||||
p.once.Do(func() {
|
||||
// Closing the master hangs up the session; the signal to the
|
||||
// shell's process group makes sure it hears, through su as well.
|
||||
err = p.f.Close()
|
||||
pid := p.cmd.Process.Pid
|
||||
_ = syscall.Kill(-pid, syscall.SIGHUP)
|
||||
select {
|
||||
case <-p.exited:
|
||||
case <-time.After(hangupGrace):
|
||||
_ = syscall.Kill(-pid, syscall.SIGKILL)
|
||||
<-p.exited
|
||||
}
|
||||
openPTYs.Delete(p)
|
||||
})
|
||||
return err
|
||||
}
|
||||
+25
-19
@@ -15,33 +15,33 @@ import (
|
||||
|
||||
// --- direct TCP mux tunnel ---
|
||||
|
||||
// runTunnel keeps a persistent connection to the host's 8446 tunnel, dialing
|
||||
// client-side ports on demand for "tcp" forward rules.
|
||||
func (a *Agent) runTunnel(ctx context.Context) {
|
||||
// runTunnel keeps a persistent connection to the host's tunnel at addr, dialing
|
||||
// client-side ports on demand for "tcp" forward rules, until ctx is cancelled.
|
||||
func (a *Agent) runTunnel(ctx context.Context, addr string) {
|
||||
backoff := time.Second
|
||||
for ctx.Err() == nil {
|
||||
if err := a.tunnelOnce(ctx); err != nil {
|
||||
if err := a.tunnelOnce(ctx, addr); err != nil {
|
||||
select {
|
||||
case <-time.After(backoff):
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
if backoff < 15*time.Second {
|
||||
backoff *= 2
|
||||
}
|
||||
backoff = min(backoff*2, retryCap)
|
||||
continue
|
||||
}
|
||||
backoff = time.Second
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) tunnelOnce(ctx context.Context) error {
|
||||
func (a *Agent) tunnelOnce(ctx context.Context, addr string) error {
|
||||
d := net.Dialer{Timeout: 10 * time.Second}
|
||||
conn, err := d.DialContext(ctx, "tcp", a.cfg.TunnelAddr)
|
||||
conn, err := d.DialContext(ctx, "tcp", addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
// Unblock the read loop when the tunnel is stopped or moved.
|
||||
defer context.AfterFunc(ctx, func() { conn.Close() })()
|
||||
if tc, ok := conn.(*net.TCPConn); ok {
|
||||
_ = tc.SetNoDelay(true)
|
||||
}
|
||||
@@ -65,7 +65,7 @@ func (a *Agent) tunnelOnce(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
_ = conn.SetDeadline(time.Time{})
|
||||
log.Printf("tunnel: connected to %s", a.cfg.TunnelAddr)
|
||||
log.Printf("tunnel: connected to %s", addr)
|
||||
|
||||
tc := &tunClient{conn: conn, streams: map[uint32]*agentStream{}}
|
||||
return tc.readLoop()
|
||||
@@ -94,8 +94,14 @@ func (t *tunClient) write(f tunnel.Frame) error {
|
||||
return tunnel.WriteFrame(t.conn, f)
|
||||
}
|
||||
|
||||
// tunnelSilence is how long the tunnel may say nothing before it is taken
|
||||
// for dead: the server pings every 20 s, so this is three missed pings - a
|
||||
// connection a NAT forgot while the machine slept, say.
|
||||
const tunnelSilence = 65 * time.Second
|
||||
|
||||
func (t *tunClient) readLoop() error {
|
||||
for {
|
||||
_ = t.conn.SetReadDeadline(time.Now().Add(tunnelSilence))
|
||||
f, err := tunnel.ReadFrame(t.conn)
|
||||
if err != nil {
|
||||
t.shutdown()
|
||||
@@ -194,28 +200,26 @@ func (t *tunClient) shutdown() {
|
||||
|
||||
// --- direct UDP forwarding ---
|
||||
|
||||
// runUDP maintains the agent's UDP endpoint on the host's 8446 channel and
|
||||
// runUDP maintains the agent's UDP endpoint on the host's tunnel at addr and
|
||||
// relays datagrams to/from client-side UDP ports for "udp" forward rules.
|
||||
func (a *Agent) runUDP(ctx context.Context) {
|
||||
func (a *Agent) runUDP(ctx context.Context, addr string) {
|
||||
backoff := time.Second
|
||||
for ctx.Err() == nil {
|
||||
if err := a.udpOnce(ctx); err != nil {
|
||||
if err := a.udpOnce(ctx, addr); err != nil {
|
||||
select {
|
||||
case <-time.After(backoff):
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
if backoff < 15*time.Second {
|
||||
backoff *= 2
|
||||
}
|
||||
backoff = min(backoff*2, retryCap)
|
||||
continue
|
||||
}
|
||||
backoff = time.Second
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) udpOnce(ctx context.Context) error {
|
||||
raddr, err := net.ResolveUDPAddr("udp", a.cfg.TunnelAddr)
|
||||
func (a *Agent) udpOnce(ctx context.Context, addr string) error {
|
||||
raddr, err := net.ResolveUDPAddr("udp", addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -224,6 +228,8 @@ func (a *Agent) udpOnce(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
// Unblock the read loop when the tunnel is stopped or moved.
|
||||
defer context.AfterFunc(ctx, func() { conn.Close() })()
|
||||
|
||||
key := tunnel.AuthKey(a.state.Token)
|
||||
register := func() {
|
||||
@@ -268,7 +274,7 @@ func (a *Agent) udpOnce(ctx context.Context) error {
|
||||
}
|
||||
|
||||
type udpClient struct {
|
||||
server *net.UDPConn // connection back to host:8446
|
||||
server *net.UDPConn // connection back to the host tunnel
|
||||
mu sync.Mutex
|
||||
dials map[uint32]*net.UDPConn // sid -> client-side socket
|
||||
}
|
||||
|
||||
+120
-27
@@ -3,31 +3,40 @@ package agent
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||
)
|
||||
|
||||
// doUpdate downloads the latest agent binary for this platform, atomically
|
||||
// replaces the running executable, and schedules a service restart. It never
|
||||
// uninstalls — only updates.
|
||||
// doUpdate downloads the latest agent binary for this platform, replaces the
|
||||
// running executable and restarts the service. It runs as root when it can:
|
||||
// the agent's own user if that is root, otherwise sudo when it needs no
|
||||
// password. It never uninstalls - only updates.
|
||||
func (a *Agent) doUpdate() proto.TaskResult {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return proto.TaskResult{Error: "не удалось определить путь бинарника: " + err.Error()}
|
||||
}
|
||||
exe, _ = filepath.EvalSymlinks(exe)
|
||||
sudo := ""
|
||||
if os.Geteuid() != 0 {
|
||||
sudo = sudoPath()
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/download/agent-%s-%s", a.cfg.Host, runtime.GOOS, runtime.GOARCH)
|
||||
url := fmt.Sprintf("%s/download/agent-%s-%s", a.host(), runtime.GOOS, runtime.GOARCH)
|
||||
req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
|
||||
resp, err := a.http.Do(req)
|
||||
resp, err := a.stream.Do(req)
|
||||
if err != nil {
|
||||
return proto.TaskResult{Error: "скачивание: " + err.Error()}
|
||||
}
|
||||
@@ -36,41 +45,125 @@ func (a *Agent) doUpdate() proto.TaskResult {
|
||||
return proto.TaskResult{Error: "скачивание: сервер вернул " + resp.Status}
|
||||
}
|
||||
|
||||
tmp := exe + ".new"
|
||||
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755)
|
||||
if err != nil {
|
||||
return proto.TaskResult{Error: "создание файла: " + err.Error()}
|
||||
// The new binary lands next to the old one, or, when root's hand is
|
||||
// needed to put it there, in a temporary folder first.
|
||||
dir := filepath.Dir(exe)
|
||||
if sudo != "" {
|
||||
dir = ""
|
||||
}
|
||||
if _, err := io.Copy(f, resp.Body); err != nil {
|
||||
f.Close()
|
||||
os.Remove(tmp)
|
||||
f, err := os.CreateTemp(dir, ".lpagent-new-*")
|
||||
if err != nil {
|
||||
return proto.TaskResult{Error: "создание файла: " + err.Error() + userNote()}
|
||||
}
|
||||
tmp := f.Name()
|
||||
defer os.Remove(tmp)
|
||||
n, err := io.Copy(f, io.LimitReader(resp.Body, 256<<20))
|
||||
if cerr := f.Close(); err == nil {
|
||||
err = cerr
|
||||
}
|
||||
if err != nil {
|
||||
return proto.TaskResult{Error: "запись: " + err.Error()}
|
||||
}
|
||||
f.Close()
|
||||
if err := checkBinary(tmp, n); err != nil {
|
||||
return proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
if err := os.Chmod(tmp, 0o755); err != nil {
|
||||
return proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
|
||||
// Atomic replace: renaming over a running executable is allowed on Linux;
|
||||
// the live process keeps the old inode until it restarts.
|
||||
if err := os.Rename(tmp, exe); err != nil {
|
||||
os.Remove(tmp)
|
||||
return proto.TaskResult{Error: "замена бинарника: " + err.Error()}
|
||||
how := "от " + currentUser()
|
||||
if sudo != "" {
|
||||
// install copies it in place under a new inode, as a rename would.
|
||||
if out, err := exec.Command(sudo, "-n", "install", "-m", "0755", tmp, exe).CombinedOutput(); err != nil {
|
||||
return proto.TaskResult{Error: "замена бинарника через sudo: " + strings.TrimSpace(string(out)+" "+err.Error())}
|
||||
}
|
||||
how = "через sudo"
|
||||
} else if err := os.Rename(tmp, exe); err != nil {
|
||||
// Renaming over a running executable is allowed; the live process
|
||||
// keeps the old inode until it restarts.
|
||||
return proto.TaskResult{Error: "замена бинарника: " + err.Error() + userNote()}
|
||||
}
|
||||
|
||||
// Restart shortly, after the result has been reported.
|
||||
go func() {
|
||||
time.Sleep(1500 * time.Millisecond)
|
||||
restartSelf()
|
||||
restartSelf(exe, sudo)
|
||||
}()
|
||||
|
||||
data, _ := json.Marshal(map[string]string{"status": "обновлено, перезапуск"})
|
||||
data, _ := json.Marshal(map[string]string{"status": "обновлено " + how + ", перезапуск"})
|
||||
return proto.TaskResult{OK: true, Data: data}
|
||||
}
|
||||
|
||||
// restartSelf restarts the agent so the new binary takes effect.
|
||||
func restartSelf() {
|
||||
if path, err := exec.LookPath("systemctl"); err == nil {
|
||||
_ = exec.Command(path, "restart", "lpagent").Start()
|
||||
return
|
||||
// sudoPath is sudo when it lets this user run commands without a password.
|
||||
func sudoPath() string {
|
||||
p, err := exec.LookPath("sudo")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
if exec.Command(p, "-n", "true").Run() != nil {
|
||||
return ""
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func userNote() string {
|
||||
if os.Geteuid() == 0 {
|
||||
return ""
|
||||
}
|
||||
return " (агент запущен от " + currentUser() + ", без root и sudo без пароля - обновите установщиком)"
|
||||
}
|
||||
|
||||
// checkBinary makes sure what came is a program for this system, not an
|
||||
// error page: a binary of a sensible size with the right header.
|
||||
func checkBinary(path string, size int64) error {
|
||||
if size < 1<<20 {
|
||||
return fmt.Errorf("скачанный файл слишком мал (%d байт) - не похоже на агента", size)
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
var magic [4]byte
|
||||
if _, err := io.ReadFull(f, magic[:]); err != nil {
|
||||
return err
|
||||
}
|
||||
ok := false
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
ok = string(magic[:]) == "\x7fELF"
|
||||
case "darwin":
|
||||
ok = magic == [4]byte{0xcf, 0xfa, 0xed, 0xfe} || magic == [4]byte{0xca, 0xfe, 0xba, 0xbe}
|
||||
default:
|
||||
ok = true
|
||||
}
|
||||
if !ok {
|
||||
return errors.New("скачанный файл - не программа для " + runtime.GOOS)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// restartSelf starts the new binary. Under systemd the service is restarted
|
||||
// (queued, so it goes on after this process is stopped); otherwise the
|
||||
// process replaces itself with the new binary.
|
||||
func restartSelf(exe, sudo string) {
|
||||
if os.Getenv("INVOCATION_ID") != "" {
|
||||
if path, err := exec.LookPath("systemctl"); err == nil {
|
||||
args := []string{"--no-block", "restart", "lpagent"}
|
||||
cmd := exec.Command(path, args...)
|
||||
if sudo != "" {
|
||||
cmd = exec.Command(sudo, append([]string{"-n", path}, args...)...)
|
||||
}
|
||||
if cmd.Run() == nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
// systemd brings a service that stopped back up (Restart=always).
|
||||
os.Exit(0)
|
||||
}
|
||||
CloseTerminals()
|
||||
if err := syscall.Exec(exe, os.Args, os.Environ()); err != nil {
|
||||
log.Printf("update: restart: %v", err)
|
||||
os.Exit(0)
|
||||
}
|
||||
// No systemd (e.g. launchd/manual run): exit so the supervisor restarts us.
|
||||
os.Exit(0)
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
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)
|
||||
case proto.TaskDBTables, proto.TaskDBRows, proto.TaskDBSet, proto.TaskDBAdd, proto.TaskDBDel, proto.TaskDBSQL:
|
||||
return dbTask(env)
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/coder/websocket/wsjson"
|
||||
|
||||
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||
)
|
||||
|
||||
// The WebSocket to the server: control messages arrive on it as soon as they
|
||||
// are queued, and terminal and http-forward sessions send and take their
|
||||
// bytes over it instead of a request each. When it drops, sessions carry on
|
||||
// over plain requests.
|
||||
|
||||
const wsPing = 20 * time.Second
|
||||
|
||||
type wsLink struct {
|
||||
c *websocket.Conn
|
||||
ctx context.Context
|
||||
mu sync.Mutex
|
||||
subs map[string]chan wsIn
|
||||
asked map[string]bool // sessions whose input was asked for
|
||||
done chan struct{}
|
||||
stop context.CancelFunc
|
||||
}
|
||||
|
||||
// close drops the link: the agent then connects again, to the address in
|
||||
// use by then.
|
||||
func (l *wsLink) close() { l.stop() }
|
||||
|
||||
// wsIn is what the server sent for a session: its input, or that it is over.
|
||||
type wsIn struct {
|
||||
frames []proto.InFrame
|
||||
gone bool
|
||||
}
|
||||
|
||||
func (l *wsLink) send(m proto.WSMsg) error {
|
||||
ctx, cancel := context.WithTimeout(l.ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
return wsjson.Write(ctx, l.c, m)
|
||||
}
|
||||
|
||||
// subscribe asks the server for a session's input on this link, once, and
|
||||
// returns where it arrives; nil when the link cannot take it.
|
||||
func (l *wsLink) subscribe(sid string) chan wsIn {
|
||||
l.mu.Lock()
|
||||
ch := l.subs[sid]
|
||||
if ch == nil {
|
||||
ch = make(chan wsIn, 64)
|
||||
l.subs[sid] = ch
|
||||
}
|
||||
fresh := !l.subscribed(sid)
|
||||
l.mu.Unlock()
|
||||
if fresh && l.send(proto.WSMsg{T: "sub", SID: sid}) != nil {
|
||||
return nil
|
||||
}
|
||||
return ch
|
||||
}
|
||||
|
||||
// subscribed marks a session as asked for; l.mu is held.
|
||||
func (l *wsLink) subscribed(sid string) bool {
|
||||
if l.asked == nil {
|
||||
l.asked = map[string]bool{}
|
||||
}
|
||||
if l.asked[sid] {
|
||||
return true
|
||||
}
|
||||
l.asked[sid] = true
|
||||
return false
|
||||
}
|
||||
|
||||
func (l *wsLink) unsubscribe(sid string) {
|
||||
l.mu.Lock()
|
||||
delete(l.subs, sid)
|
||||
delete(l.asked, sid)
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
func (l *wsLink) deliver(sid string, in wsIn) {
|
||||
l.mu.Lock()
|
||||
ch := l.subs[sid]
|
||||
l.mu.Unlock()
|
||||
if ch == nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case ch <- in:
|
||||
case <-l.done:
|
||||
}
|
||||
}
|
||||
|
||||
// runWS holds the WebSocket until it ends, dispatching what comes on it.
|
||||
func (a *Agent) runWS(ctx context.Context) error {
|
||||
u := a.host() + "/api/agent/ws"
|
||||
u = "ws" + strings.TrimPrefix(u, "http")
|
||||
hdr := http.Header{}
|
||||
hdr.Set("X-Agent-Id", a.state.AgentID)
|
||||
hdr.Set("Authorization", "Bearer "+a.state.Token)
|
||||
hdr.Set("X-Agent-Version", Version)
|
||||
hdr.Set("X-Agent-User", currentUser())
|
||||
dctx, dcancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
c, resp, err := websocket.Dial(dctx, u, &websocket.DialOptions{
|
||||
HTTPClient: a.stream, HTTPHeader: hdr, CompressionMode: websocket.CompressionDisabled,
|
||||
})
|
||||
dcancel()
|
||||
if err != nil {
|
||||
if resp != nil {
|
||||
a.applyTransport(resp.Header.Get(proto.TransportHeader))
|
||||
return fmt.Errorf("server answered %s", resp.Status)
|
||||
}
|
||||
return err
|
||||
}
|
||||
c.SetReadLimit(8 << 20)
|
||||
lctx, lcancel := context.WithCancel(ctx)
|
||||
l := &wsLink{c: c, ctx: lctx, subs: map[string]chan wsIn{}, done: make(chan struct{}), stop: lcancel}
|
||||
a.link.Store(l)
|
||||
defer func() {
|
||||
a.link.CompareAndSwap(l, nil)
|
||||
close(l.done)
|
||||
lcancel()
|
||||
c.CloseNow()
|
||||
}()
|
||||
log.Printf("connected by websocket")
|
||||
a.reached()
|
||||
go func() {
|
||||
t := time.NewTicker(wsPing)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-lctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
pctx, pcancel := context.WithTimeout(lctx, 15*time.Second)
|
||||
err := c.Ping(pctx)
|
||||
pcancel()
|
||||
if err != nil {
|
||||
lcancel()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
for {
|
||||
var m proto.WSMsg
|
||||
if err := wsjson.Read(lctx, c, &m); err != nil {
|
||||
if errors.Is(err, context.Canceled) && ctx.Err() == nil {
|
||||
return errors.New("no answer to ping")
|
||||
}
|
||||
return err
|
||||
}
|
||||
switch m.T {
|
||||
case "ctrl":
|
||||
if m.Ctrl != nil {
|
||||
// Sessions live on the agent's context: they outlast the link.
|
||||
a.dispatch(ctx, *m.Ctrl)
|
||||
}
|
||||
case "cfg":
|
||||
a.applyTunnelAddr(ctx, m.Tunnel)
|
||||
a.learnAddrs(m.Main, m.Fallback)
|
||||
a.applyTransport(m.Transport)
|
||||
if a.mode() == proto.TransportLP {
|
||||
return errors.New("set to long polling")
|
||||
}
|
||||
case "in":
|
||||
l.deliver(m.SID, wsIn{frames: m.Frames})
|
||||
case "gone":
|
||||
l.deliver(m.SID, wsIn{gone: true})
|
||||
}
|
||||
}
|
||||
}
|
||||
+220
-5
@@ -18,8 +18,23 @@ const (
|
||||
CtrlOpenForward = "open_forward"
|
||||
// CtrlTask tells the agent to fetch and run the one-shot task TaskID.
|
||||
CtrlTask = "task"
|
||||
// CtrlOpenSSH tells the agent to open an SSH connection to SSH and bridge
|
||||
// its shell to the session id, as a terminal.
|
||||
CtrlOpenSSH = "open_ssh"
|
||||
)
|
||||
|
||||
// SSHTarget is where an SSH terminal connects and how it signs in: a
|
||||
// password, a private key (PEM or OpenSSH, with its passphrase if it has
|
||||
// one), or, with neither, the agent user's own keys in ~/.ssh.
|
||||
type SSHTarget struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port,omitempty"`
|
||||
User string `json:"user"`
|
||||
Password string `json:"password,omitempty"`
|
||||
Key string `json:"key,omitempty"`
|
||||
Passphrase string `json:"passphrase,omitempty"`
|
||||
}
|
||||
|
||||
// EnrollRequest is sent once by a freshly installed agent.
|
||||
type EnrollRequest struct {
|
||||
Name string `json:"name"`
|
||||
@@ -27,6 +42,7 @@ type EnrollRequest struct {
|
||||
OS string `json:"os"`
|
||||
Arch string `json:"arch"`
|
||||
Version string `json:"version"`
|
||||
User string `json:"user,omitempty"` // OS user the agent runs as
|
||||
}
|
||||
|
||||
// EnrollResponse carries the identity the agent persists and uses from then on.
|
||||
@@ -37,10 +53,49 @@ type EnrollResponse struct {
|
||||
|
||||
// ControlMessage is the body returned by the control long-poll.
|
||||
type ControlMessage struct {
|
||||
Type string `json:"type"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
Port int `json:"port,omitempty"` // CtrlOpenForward: client-side port to dial
|
||||
TaskID string `json:"task_id,omitempty"` // CtrlTask
|
||||
Type string `json:"type"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
Port int `json:"port,omitempty"` // CtrlOpenForward: client-side port to dial
|
||||
TaskID string `json:"task_id,omitempty"` // CtrlTask
|
||||
User string `json:"user,omitempty"` // CtrlOpenTerminal: OS user for the shell ("" = the agent's own)
|
||||
SSH *SSHTarget `json:"ssh,omitempty"` // CtrlOpenSSH
|
||||
}
|
||||
|
||||
// Transports an agent can reach the server by.
|
||||
const (
|
||||
TransportAuto = "auto" // WebSocket, long polling while it cannot connect
|
||||
TransportWS = "ws" // WebSocket only
|
||||
TransportLP = "lp" // long polling only
|
||||
)
|
||||
|
||||
// TransportHeader carries the agent's transport setting on poll and config
|
||||
// answers.
|
||||
const TransportHeader = "X-Transport"
|
||||
|
||||
// FallbackHeader carries the server's direct address (one not behind a
|
||||
// proxy like Cloudflare) on poll and config answers, for the agent to turn
|
||||
// to when the main one is out of reach.
|
||||
const FallbackHeader = "X-Fallback-Host"
|
||||
|
||||
// MainHeader carries the server's main address beside FallbackHeader, for an
|
||||
// agent set to the direct one to fall back to the main one.
|
||||
const MainHeader = "X-Main-Host"
|
||||
|
||||
// WSMsg is one message on an agent's WebSocket, either way. Server to agent:
|
||||
// "ctrl" (Ctrl, as a control poll would answer), "cfg" (Tunnel and
|
||||
// Transport, also a keep-alive), "in" (Frames for session SID), "gone"
|
||||
// (session SID is over). Agent to server: "sub" (send the input of session
|
||||
// SID here), "out" (Out of session SID).
|
||||
type WSMsg struct {
|
||||
T string `json:"t"`
|
||||
Ctrl *ControlMessage `json:"ctrl,omitempty"`
|
||||
SID string `json:"sid,omitempty"`
|
||||
Frames []InFrame `json:"frames,omitempty"`
|
||||
Out *OutMessage `json:"out,omitempty"`
|
||||
Tunnel string `json:"tunnel,omitempty"`
|
||||
Transport string `json:"transport,omitempty"`
|
||||
Fallback string `json:"fallback,omitempty"`
|
||||
Main string `json:"main,omitempty"`
|
||||
}
|
||||
|
||||
// Terminal / stream frame types on the session "in" channel (server -> agent).
|
||||
@@ -68,8 +123,20 @@ type InBatch struct {
|
||||
type OutMessage struct {
|
||||
Data string `json:"data,omitempty"` // base64 bytes
|
||||
Exit bool `json:"exit,omitempty"`
|
||||
// Status tells the panel how an SSH terminal's connection goes: one of
|
||||
// the SSHStatus values, with Note to show.
|
||||
Status string `json:"status,omitempty"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
// The steps of an SSH terminal's connection, as OutMessage.Status.
|
||||
const (
|
||||
SSHDial = "dial" // connecting to the host
|
||||
SSHAuth = "auth" // the host answered, signing in
|
||||
SSHOK = "ok" // signed in, the shell runs
|
||||
SSHErr = "err" // it failed; Note says why
|
||||
)
|
||||
|
||||
// --- one-shot tasks (file manager, native in the agent) ---
|
||||
|
||||
const (
|
||||
@@ -83,10 +150,44 @@ const (
|
||||
TaskFSZip = "fs.zip" // zip selected paths (native archive/zip)
|
||||
TaskFSMove = "fs.move" // move/rename
|
||||
TaskFSDownload = "fs.download" // read a whole file (larger cap) for browser download
|
||||
TaskFSStat = "fs.stat" // a file's properties: type, size, mode, owner
|
||||
TaskFSChmod = "fs.chmod" // change mode and owner, of a folder's contents too
|
||||
TaskFSCopy = "fs.copy" // copy or move, with a rule for what is already there
|
||||
TaskFSPut = "fs.put" // write a file the browser sends in pieces
|
||||
TaskFSSend = "fs.send" // send a file to the browser in pieces
|
||||
|
||||
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)
|
||||
TaskDBTables = "db.tables" // an SQLite database's tables and views
|
||||
TaskDBRows = "db.rows" // a page of a table's rows, with each row's key
|
||||
TaskDBSet = "db.set" // set one value of one row
|
||||
TaskDBAdd = "db.add" // add a row
|
||||
TaskDBDel = "db.del" // delete rows by their keys
|
||||
TaskDBSQL = "db.sql" // run a statement typed in the panel
|
||||
TaskFSShipOut = "fs.ship.out" // pack a file or folder and send it to another machine
|
||||
TaskFSShipIn = "fs.ship.in" // unpack what another machine sends
|
||||
)
|
||||
|
||||
// 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
|
||||
|
||||
// FSFileCap bounds a file sent either way in pieces (TaskFSPut, TaskFSSend).
|
||||
const FSFileCap = 512 << 20
|
||||
|
||||
// FSXferArgs are the arguments of TaskFSPut and TaskFSSend: the file, and the
|
||||
// transfer on the server whose pieces carry it - GET them for a put, POST them
|
||||
// for a send, Chunk bytes each. Size is a put's length; -1 for a send.
|
||||
type FSXferArgs struct {
|
||||
Path string `json:"path"`
|
||||
Xfer string `json:"xfer"`
|
||||
Size int64 `json:"size"`
|
||||
Chunk int `json:"chunk"`
|
||||
}
|
||||
|
||||
// TaskEnvelope is what the agent fetches for a task id.
|
||||
type TaskEnvelope struct {
|
||||
Type string `json:"type"`
|
||||
@@ -121,6 +222,47 @@ type FSMoveArgs struct {
|
||||
Dst string `json:"dst"`
|
||||
}
|
||||
|
||||
// FSStatResult is returned for TaskFSStat. Mode holds the permission bits
|
||||
// with setuid, setgid and sticky (07777). Users and Groups are the names on
|
||||
// the machine, offered when choosing an owner.
|
||||
type FSStatResult struct {
|
||||
Path string `json:"path"`
|
||||
IsDir bool `json:"is_dir"`
|
||||
Link string `json:"link,omitempty"` // where a symlink points
|
||||
Size int64 `json:"size"`
|
||||
MTime int64 `json:"mtime"`
|
||||
Mode uint32 `json:"mode"`
|
||||
UID int `json:"uid"`
|
||||
GID int `json:"gid"`
|
||||
Owner string `json:"owner"`
|
||||
Group string `json:"group"`
|
||||
Users []string `json:"users"`
|
||||
Groups []string `json:"groups"`
|
||||
}
|
||||
|
||||
// FSChmodArgs are the arguments of TaskFSChmod. Mode nil, Owner or Group
|
||||
// empty leave that as it is. Recursive applies to everything inside a folder
|
||||
// as well, Only narrowing it to "files" or "dirs".
|
||||
type FSChmodArgs struct {
|
||||
Path string `json:"path"`
|
||||
Mode *uint32 `json:"mode,omitempty"`
|
||||
Owner string `json:"owner,omitempty"`
|
||||
Group string `json:"group,omitempty"`
|
||||
Recursive bool `json:"recursive,omitempty"`
|
||||
Only string `json:"only,omitempty"`
|
||||
}
|
||||
|
||||
// FSCopyArgs are the arguments of TaskFSCopy: Src goes to Dst (the full new
|
||||
// path), copied or, with Move, moved. Conflict says what to do when Dst is
|
||||
// already there: "error" (the default) stops, "replace" puts Src in its place,
|
||||
// "skip" keeps it. Folders on both sides are merged under replace and skip.
|
||||
type FSCopyArgs struct {
|
||||
Src string `json:"src"`
|
||||
Dst string `json:"dst"`
|
||||
Move bool `json:"move,omitempty"`
|
||||
Conflict string `json:"conflict,omitempty"`
|
||||
}
|
||||
|
||||
// FSEntry is one directory entry.
|
||||
type FSEntry struct {
|
||||
Name string `json:"name"`
|
||||
@@ -144,3 +286,76 @@ type FSReadResult struct {
|
||||
Truncated bool `json:"truncated"`
|
||||
Binary bool `json:"binary"`
|
||||
}
|
||||
|
||||
// SysUsersResult is returned for TaskSysUsers.
|
||||
type SysUsersResult struct {
|
||||
Current string `json:"current"` // user the agent runs as
|
||||
CanSwitch bool `json:"can_switch"` // agent is root, so it can open shells as (and create) other users
|
||||
Users []string `json:"users"`
|
||||
}
|
||||
|
||||
// SysUserAddArgs are the arguments of TaskSysUserAdd.
|
||||
type SysUserAddArgs struct {
|
||||
Name string `json:"name"`
|
||||
Password string `json:"password,omitempty"` // empty leaves the account without a password
|
||||
Sudo bool `json:"sudo"` // add to the sudo/wheel group
|
||||
}
|
||||
|
||||
// SysStatResult is returned for TaskSysStat. Sizes are in bytes.
|
||||
type SysStatResult struct {
|
||||
CPU float64 `json:"cpu"` // load across all cores, 0..100
|
||||
NCPU int `json:"ncpu"`
|
||||
MemTotal uint64 `json:"mem_total"`
|
||||
MemUsed uint64 `json:"mem_used"`
|
||||
DiskTotal uint64 `json:"disk_total"` // filesystem holding "/"
|
||||
DiskFree uint64 `json:"disk_free"`
|
||||
}
|
||||
|
||||
// SysSpeedArgs are the arguments of TaskSysSpeed.
|
||||
type SysSpeedArgs struct {
|
||||
// Relay makes the agent use the relayed streams, which the server pipes
|
||||
// to and from the browser.
|
||||
Relay bool `json:"relay,omitempty"`
|
||||
// WindowMs is how long to measure each direction for; 0 leaves it to the
|
||||
// agent (3 s, all an agent that predates this argument does).
|
||||
WindowMs int `json:"window_ms,omitempty"`
|
||||
}
|
||||
|
||||
// SysSpeedResult is returned for TaskSysSpeed: the agent's HTTP link to the
|
||||
// server as the agent measured it. Down is server -> agent.
|
||||
type SysSpeedResult struct {
|
||||
PingMs float64 `json:"ping_ms"`
|
||||
DownBps float64 `json:"down_bps"` // bits per second
|
||||
UpBps float64 `json:"up_bps"`
|
||||
Relay bool `json:"relay,omitempty"` // the streams went through the relay
|
||||
// Partial marks a test the server cut short: the numbers are what it had
|
||||
// counted on its own side by then.
|
||||
Partial bool `json:"partial,omitempty"`
|
||||
}
|
||||
|
||||
// FSShipArgs are the arguments of TaskFSShipOut and TaskFSShipIn: Src (on the
|
||||
// sending machine) lands in Dir as Name (on the receiving one), following
|
||||
// Conflict as FSCopyArgs does. The sender packs it as a tar stream and either
|
||||
// posts it in pieces of Chunk bytes to Relay on the server, for the receiver
|
||||
// to take, or with SSH set writes it to tar on that host itself, and with
|
||||
// Move then deletes Src. Both report to Job how far they are.
|
||||
type FSShipArgs struct {
|
||||
Src string `json:"src,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Dir string `json:"dir"`
|
||||
Conflict string `json:"conflict,omitempty"`
|
||||
Move bool `json:"move,omitempty"`
|
||||
Relay string `json:"relay,omitempty"`
|
||||
Chunk int `json:"chunk,omitempty"`
|
||||
Job string `json:"job,omitempty"`
|
||||
SSH *SSHTarget `json:"ssh,omitempty"`
|
||||
}
|
||||
|
||||
// ShipProgress is what a sending machine reports: bytes of files sent out of
|
||||
// Total, and files done.
|
||||
type ShipProgress struct {
|
||||
Total int64 `json:"total"`
|
||||
Done int64 `json:"done"`
|
||||
Files int `json:"files"`
|
||||
Note string `json:"note,omitempty"` // what is going on: connecting, signing in
|
||||
}
|
||||
@@ -24,6 +24,16 @@ func (s *Server) authenticateAgent(r *http.Request) *Agent {
|
||||
return a
|
||||
}
|
||||
|
||||
// online reports whether the agent is connected right now: the hub knows from
|
||||
// its control polls. Right after a start it cannot tell yet, so the last poll
|
||||
// recorded in the database decides.
|
||||
func (s *Server) online(a *Agent) bool {
|
||||
if on, ok := s.hub.Online(a.ID); ok {
|
||||
return on
|
||||
}
|
||||
return a.recentlySeen()
|
||||
}
|
||||
|
||||
func bearerToken(r *http.Request) string {
|
||||
h := r.Header.Get("Authorization")
|
||||
if strings.HasPrefix(h, "Bearer ") {
|
||||
@@ -34,7 +44,7 @@ func bearerToken(r *http.Request) string {
|
||||
|
||||
// enroll creates a new agent record for a valid enroll secret and returns the
|
||||
// generated id plus a one-time plaintext token (only the hash is stored).
|
||||
func (s *Server) enroll(name, hostname, os, arch, version string) (id, token string, err error) {
|
||||
func (s *Server) enroll(name, hostname, os, arch, version, runUser string) (id, token string, err error) {
|
||||
id = randToken()[:16]
|
||||
token = randToken()
|
||||
a := Agent{
|
||||
@@ -44,6 +54,7 @@ func (s *Server) enroll(name, hostname, os, arch, version string) (id, token str
|
||||
OS: os,
|
||||
Arch: arch,
|
||||
Version: version,
|
||||
RunUser: runUser,
|
||||
TokenHash: hashToken(token),
|
||||
}
|
||||
if err = s.db.CreateAgent(a); err != nil {
|
||||
|
||||
+77
-14
@@ -3,6 +3,10 @@ package server
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
lpra "github.com/x-grk/longpolling-remote-access"
|
||||
)
|
||||
|
||||
// Config keys stored in the config table.
|
||||
@@ -11,16 +15,20 @@ const (
|
||||
cfgEnrollSecret = "enroll_secret"
|
||||
cfgSetupDone = "setup_done"
|
||||
cfgInstallCode = "install_code"
|
||||
cfgInstallLen = "install_code_len"
|
||||
cfgUfwEnabled = "ufw_enabled"
|
||||
cfgTunnelHost = "tunnel_host"
|
||||
cfgTunnelPort = "tunnel_port"
|
||||
cfgDirectAddr = "direct_address"
|
||||
)
|
||||
|
||||
// DefaultHostAddress is the public URL agents dial by default. It is baked into
|
||||
// the rendered install.sh and can be changed from the settings page.
|
||||
const DefaultHostAddress = "https://REDACTED:8445"
|
||||
|
||||
// Version is the server build version, injected at compile time via -ldflags
|
||||
// (-X .../internal/server.Version=...). It is the newest version agents update to.
|
||||
var Version = "dev"
|
||||
// Version is the server build version (see VERSION.txt). It is the newest
|
||||
// version agents update to.
|
||||
var Version = lpra.Version
|
||||
|
||||
// ensureDefaults seeds the host address and a random enroll secret on first boot.
|
||||
func (db *DB) ensureDefaults() error {
|
||||
@@ -47,7 +55,7 @@ func (db *DB) ensureDefaults() error {
|
||||
return err
|
||||
}
|
||||
if code == "" {
|
||||
if err := db.SetConfig(cfgInstallCode, shortCode()); err != nil {
|
||||
if err := db.SetConfig(cfgInstallCode, shortCode(db.InstallCodeLen())); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -62,6 +70,23 @@ func (db *DB) HostAddress() string {
|
||||
return v
|
||||
}
|
||||
|
||||
// DirectAddress is the server's direct URL, one that is not behind a proxy
|
||||
// like Cloudflare: the agents' fallback, and a way to install when the main
|
||||
// address is out of reach. Empty when not set.
|
||||
func (db *DB) DirectAddress() string {
|
||||
v, _ := db.GetConfig(cfgDirectAddr)
|
||||
return v
|
||||
}
|
||||
|
||||
// directHost is the direct address's host name, without scheme and port.
|
||||
func directHost(addr string) string {
|
||||
u, err := url.Parse(addr)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return u.Hostname()
|
||||
}
|
||||
|
||||
func (db *DB) EnrollSecret() string {
|
||||
v, _ := db.GetConfig(cfgEnrollSecret)
|
||||
return v
|
||||
@@ -86,6 +111,24 @@ func (db *DB) SetUfwEnabled(on bool) error {
|
||||
return db.SetConfig(cfgUfwEnabled, "0")
|
||||
}
|
||||
|
||||
// TunnelHost is the host agents dial for the direct tunnel. Empty means the
|
||||
// host of the URL they already reach the panel on.
|
||||
func (db *DB) TunnelHost() string {
|
||||
v, _ := db.GetConfig(cfgTunnelHost)
|
||||
return v
|
||||
}
|
||||
|
||||
// TunnelPort is the direct-tunnel port set in Settings, or 0 when it is left
|
||||
// at the port of the server's -tunnel address.
|
||||
func (db *DB) TunnelPort() int {
|
||||
v, _ := db.GetConfig(cfgTunnelPort)
|
||||
n, _ := strconv.Atoi(v)
|
||||
if n < 1 || n > 65535 {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (db *DB) SetupDone() bool {
|
||||
v, _ := db.GetConfig(cfgSetupDone)
|
||||
return v == "1"
|
||||
@@ -102,16 +145,36 @@ func randToken() string {
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// shortCode returns a short, URL-safe, typo-resistant code for install links.
|
||||
// It avoids ambiguous characters (0/O, 1/l/I) so it is easy to read and type.
|
||||
func shortCode() string {
|
||||
const alphabet = "abcdefghjkmnpqrstuvwxyz23456789"
|
||||
b := make([]byte, 7)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic("crypto/rand failed: " + err.Error())
|
||||
// Install code length: 3 by default, up to 24.
|
||||
const (
|
||||
installCodeMin = 3
|
||||
installCodeMax = 24
|
||||
installCodeDef = 3
|
||||
)
|
||||
|
||||
// InstallCodeLen is how many characters a new install code has.
|
||||
func (db *DB) InstallCodeLen() int {
|
||||
v, _ := db.GetConfig(cfgInstallLen)
|
||||
if n, err := strconv.Atoi(v); err == nil && n >= installCodeMin && n <= installCodeMax {
|
||||
return n
|
||||
}
|
||||
for i := range b {
|
||||
b[i] = alphabet[int(b[i])%len(alphabet)]
|
||||
return installCodeDef
|
||||
}
|
||||
|
||||
// shortCode returns a random code of n English letters and digits for the
|
||||
// /i/<code> install link. Every character is equally likely.
|
||||
func shortCode(n int) string {
|
||||
const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
out := make([]byte, 0, n)
|
||||
b := make([]byte, 1)
|
||||
for len(out) < n {
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic("crypto/rand failed: " + err.Error())
|
||||
}
|
||||
// 248 = 4 * 62: drop the rest, so no character comes up more often.
|
||||
if b[0] < 248 {
|
||||
out = append(out, alphabet[int(b[0])%len(alphabet)])
|
||||
}
|
||||
}
|
||||
return string(b)
|
||||
return string(out)
|
||||
}
|
||||
+328
-38
@@ -59,6 +59,8 @@ CREATE TABLE IF NOT EXISTS agents (
|
||||
arch TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL,
|
||||
version TEXT NOT NULL DEFAULT '',
|
||||
run_user TEXT NOT NULL DEFAULT '',
|
||||
login_user TEXT NOT NULL DEFAULT '',
|
||||
enrolled_at INTEGER NOT NULL,
|
||||
last_seen INTEGER NOT NULL
|
||||
);
|
||||
@@ -83,6 +85,16 @@ CREATE TABLE IF NOT EXISTS ufw_rules (
|
||||
created_at INTEGER NOT NULL,
|
||||
UNIQUE(port, proto)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS admin_keys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
admin_id INTEGER NOT NULL REFERENCES admins(id) ON DELETE CASCADE,
|
||||
cred_id BLOB UNIQUE NOT NULL,
|
||||
public_key BLOB NOT NULL,
|
||||
sign_count INTEGER NOT NULL DEFAULT 0,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
last_used INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS forward_rules (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
agent_id TEXT NOT NULL,
|
||||
@@ -101,6 +113,19 @@ CREATE TABLE IF NOT EXISTS forward_rules (
|
||||
`ALTER TABLE forward_rules ADD COLUMN proto TEXT NOT NULL DEFAULT 'http'`,
|
||||
`ALTER TABLE agents ADD COLUMN version TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE ufw_rules ADD COLUMN rule_id INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE agents ADD COLUMN run_user TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE agents ADD COLUMN login_user TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE forward_rules ADD COLUMN no_ufw INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE forward_rules ADD COLUMN label TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE admins ADD COLUMN password_off INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE agents ADD COLUMN transport TEXT NOT NULL DEFAULT 'auto'`,
|
||||
`ALTER TABLE admins ADD COLUMN pin_hash TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE admins ADD COLUMN role TEXT NOT NULL DEFAULT 'admin'`,
|
||||
`ALTER TABLE admins ADD COLUMN pw_locked INTEGER NOT NULL DEFAULT 0`,
|
||||
`CREATE TABLE IF NOT EXISTS admin_agents (
|
||||
admin_id INTEGER NOT NULL REFERENCES admins(id) ON DELETE CASCADE,
|
||||
agent_id TEXT NOT NULL,
|
||||
PRIMARY KEY (admin_id, agent_id))`,
|
||||
} {
|
||||
if _, err := db.sql.Exec(alter); err != nil && !strings.Contains(err.Error(), "duplicate column") {
|
||||
return err
|
||||
@@ -136,24 +161,56 @@ func (db *DB) CountAdmins() (int, error) {
|
||||
}
|
||||
|
||||
func (db *DB) CreateAdmin(username, passHash string) error {
|
||||
_, err := db.sql.Exec(
|
||||
`INSERT INTO admins(username, pass_hash, created_at) VALUES(?, ?, ?)`,
|
||||
username, passHash, time.Now().Unix())
|
||||
_, err := db.CreateUser(username, passHash, RoleAdmin)
|
||||
return err
|
||||
}
|
||||
|
||||
// Admin is one row of the admins table.
|
||||
// CreateUser adds a panel user with the role given and returns its id.
|
||||
func (db *DB) CreateUser(username, passHash, role string) (int64, error) {
|
||||
res, err := db.sql.Exec(
|
||||
`INSERT INTO admins(username, pass_hash, created_at, role) VALUES(?, ?, ?, ?)`,
|
||||
username, passHash, time.Now().Unix(), role)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// Roles of panel users: an admin sees and runs everything; a user only the
|
||||
// machines given to them, and none of the settings.
|
||||
const (
|
||||
RoleAdmin = "admin"
|
||||
RoleUser = "user"
|
||||
)
|
||||
|
||||
// Admin is one row of the admins table: a panel user, an admin or not.
|
||||
type Admin struct {
|
||||
ID int64
|
||||
Username string
|
||||
PassHash string
|
||||
Role string
|
||||
// PwLocked keeps the user from turning the password sign-in on or off
|
||||
// themselves: an admin set it.
|
||||
PwLocked bool
|
||||
}
|
||||
|
||||
// IsAdmin reports whether the user has the run of the whole panel.
|
||||
func (a *Admin) IsAdmin() bool { return a != nil && a.Role != RoleUser }
|
||||
|
||||
const adminCols = `id, username, pass_hash, role, pw_locked`
|
||||
|
||||
func scanAdmin(row interface{ Scan(...any) error }) (*Admin, error) {
|
||||
a := &Admin{}
|
||||
var locked int
|
||||
if err := row.Scan(&a.ID, &a.Username, &a.PassHash, &a.Role, &locked); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.PwLocked = locked == 1
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (db *DB) AdminByUsername(username string) (*Admin, error) {
|
||||
a := &Admin{}
|
||||
err := db.sql.QueryRow(
|
||||
`SELECT id, username, pass_hash FROM admins WHERE username = ?`, username).
|
||||
Scan(&a.ID, &a.Username, &a.PassHash)
|
||||
a, err := scanAdmin(db.sql.QueryRow(`SELECT `+adminCols+` FROM admins WHERE username = ?`, username))
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -161,27 +218,215 @@ func (db *DB) AdminByUsername(username string) (*Admin, error) {
|
||||
}
|
||||
|
||||
func (db *DB) ListAdmins() ([]Admin, error) {
|
||||
rows, err := db.sql.Query(`SELECT id, username FROM admins ORDER BY username`)
|
||||
rows, err := db.sql.Query(`SELECT ` + adminCols + ` FROM admins ORDER BY role, username`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Admin
|
||||
for rows.Next() {
|
||||
var a Admin
|
||||
if err := rows.Scan(&a.ID, &a.Username); err != nil {
|
||||
a, err := scanAdmin(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, a)
|
||||
out = append(out, *a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// CountRole is how many users have the role.
|
||||
func (db *DB) CountRole(role string) int {
|
||||
var n int
|
||||
_ = db.sql.QueryRow(`SELECT COUNT(*) FROM admins WHERE role = ?`, role).Scan(&n)
|
||||
return n
|
||||
}
|
||||
|
||||
func (db *DB) SetUserRole(id int64, role string) error {
|
||||
_, err := db.sql.Exec(`UPDATE admins SET role = ? WHERE id = ?`, role, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) SetPwLocked(id int64, locked bool) error {
|
||||
_, err := db.sql.Exec(`UPDATE admins SET pw_locked = ? WHERE id = ?`, boolInt(locked), id)
|
||||
return err
|
||||
}
|
||||
|
||||
// UserAgents are the machines a user may use.
|
||||
func (db *DB) UserAgents(adminID int64) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
rows, err := db.sql.Query(`SELECT agent_id FROM admin_agents WHERE admin_id = ?`, adminID)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if rows.Scan(&id) == nil {
|
||||
out[id] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SetUserAgents gives the user exactly these machines.
|
||||
func (db *DB) SetUserAgents(adminID int64, agentIDs []string) error {
|
||||
tx, err := db.sql.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.Exec(`DELETE FROM admin_agents WHERE admin_id = ?`, adminID); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, id := range agentIDs {
|
||||
if _, err := tx.Exec(`INSERT OR IGNORE INTO admin_agents(admin_id, agent_id) VALUES(?, ?)`, adminID, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// CanUseAgent reports whether the user may use the machine.
|
||||
func (db *DB) CanUseAgent(a *Admin, agentID string) bool {
|
||||
if a == nil {
|
||||
return false
|
||||
}
|
||||
if a.IsAdmin() {
|
||||
return true
|
||||
}
|
||||
var n int
|
||||
_ = db.sql.QueryRow(`SELECT COUNT(*) FROM admin_agents WHERE admin_id = ? AND agent_id = ?`, a.ID, agentID).Scan(&n)
|
||||
return n > 0
|
||||
}
|
||||
|
||||
func (db *DB) DeleteAdmin(id int64) error {
|
||||
_, err := db.sql.Exec(`DELETE FROM admins WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// AdminKey is a passkey an admin signs in with.
|
||||
type AdminKey struct {
|
||||
ID int64
|
||||
AdminID int64
|
||||
CredID []byte
|
||||
PublicKey []byte // PKIX DER
|
||||
SignCount uint32
|
||||
Name string
|
||||
CreatedAt int64
|
||||
LastUsed int64
|
||||
}
|
||||
|
||||
func (db *DB) AdminByID(id int64) (*Admin, error) {
|
||||
a, err := scanAdmin(db.sql.QueryRow(`SELECT `+adminCols+` FROM admins WHERE id = ?`, id))
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return a, err
|
||||
}
|
||||
|
||||
func (db *DB) AddAdminKey(k AdminKey) error {
|
||||
_, err := db.sql.Exec(
|
||||
`INSERT INTO admin_keys(admin_id, cred_id, public_key, sign_count, name, created_at) VALUES(?, ?, ?, ?, ?, ?)`,
|
||||
k.AdminID, k.CredID, k.PublicKey, k.SignCount, k.Name, time.Now().Unix())
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) ListAdminKeys(adminID int64) ([]AdminKey, error) {
|
||||
rows, err := db.sql.Query(
|
||||
`SELECT id, admin_id, cred_id, public_key, sign_count, name, created_at, last_used
|
||||
FROM admin_keys WHERE admin_id = ? ORDER BY id`, adminID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []AdminKey
|
||||
for rows.Next() {
|
||||
var k AdminKey
|
||||
if err := rows.Scan(&k.ID, &k.AdminID, &k.CredID, &k.PublicKey, &k.SignCount, &k.Name, &k.CreatedAt, &k.LastUsed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, k)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// PasswordOff tells an admin who turned the password off: then only a
|
||||
// passkey signs them in. It holds only while they have one.
|
||||
func (db *DB) PasswordOff(adminID int64) bool {
|
||||
var off int
|
||||
_ = db.sql.QueryRow(`SELECT password_off FROM admins WHERE id = ?`, adminID).Scan(&off)
|
||||
return off == 1 && db.CountAdminKeys(adminID) > 0
|
||||
}
|
||||
|
||||
// AdminPin is the hash of the admin's PIN, "" when there is none.
|
||||
func (db *DB) AdminPin(adminID int64) string {
|
||||
var h string
|
||||
_ = db.sql.QueryRow(`SELECT pin_hash FROM admins WHERE id = ?`, adminID).Scan(&h)
|
||||
return h
|
||||
}
|
||||
|
||||
func (db *DB) SetAdminPin(adminID int64, pinHash string) error {
|
||||
_, err := db.sql.Exec(`UPDATE admins SET pin_hash = ? WHERE id = ?`, pinHash, adminID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) SetAdminPassword(adminID int64, passHash string) error {
|
||||
_, err := db.sql.Exec(`UPDATE admins SET pass_hash = ? WHERE id = ?`, passHash, adminID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) SetPasswordOff(adminID int64, off bool) error {
|
||||
v := 0
|
||||
if off {
|
||||
v = 1
|
||||
}
|
||||
_, err := db.sql.Exec(`UPDATE admins SET password_off = ? WHERE id = ?`, v, adminID)
|
||||
return err
|
||||
}
|
||||
|
||||
// CountAdminKeys is how many passkeys an admin has.
|
||||
func (db *DB) CountAdminKeys(adminID int64) int {
|
||||
var n int
|
||||
_ = db.sql.QueryRow(`SELECT COUNT(*) FROM admin_keys WHERE admin_id = ?`, adminID).Scan(&n)
|
||||
return n
|
||||
}
|
||||
|
||||
func (db *DB) AdminKeyByCredID(credID []byte) (*AdminKey, error) {
|
||||
k := &AdminKey{}
|
||||
err := db.sql.QueryRow(
|
||||
`SELECT id, admin_id, cred_id, public_key, sign_count, name, created_at, last_used
|
||||
FROM admin_keys WHERE cred_id = ?`, credID).
|
||||
Scan(&k.ID, &k.AdminID, &k.CredID, &k.PublicKey, &k.SignCount, &k.Name, &k.CreatedAt, &k.LastUsed)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return k, err
|
||||
}
|
||||
|
||||
func (db *DB) UseAdminKey(id int64, signCount uint32) error {
|
||||
_, err := db.sql.Exec(`UPDATE admin_keys SET sign_count = ?, last_used = ? WHERE id = ?`, signCount, time.Now().Unix(), id)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteAdminKey removes a passkey; with the last one gone, the password
|
||||
// signs the admin in again.
|
||||
func (db *DB) DeleteAdminKey(adminID, id int64) error {
|
||||
if _, err := db.sql.Exec(`DELETE FROM admin_keys WHERE admin_id = ? AND id = ?`, adminID, id); err != nil {
|
||||
return err
|
||||
}
|
||||
if db.CountAdminKeys(adminID) == 0 {
|
||||
return db.SetPasswordOff(adminID, false)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) DeleteAdminKeys(adminID int64) error {
|
||||
if _, err := db.sql.Exec(`DELETE FROM admin_keys WHERE admin_id = ?`, adminID); err != nil {
|
||||
return err
|
||||
}
|
||||
return db.SetPasswordOff(adminID, false)
|
||||
}
|
||||
|
||||
// --- admin sessions ---
|
||||
|
||||
func (db *DB) CreateSession(id string, adminID int64, ttl time.Duration) error {
|
||||
@@ -194,12 +439,10 @@ func (db *DB) CreateSession(id string, adminID int64, ttl time.Duration) error {
|
||||
|
||||
// SessionAdmin returns the admin for a live (non-expired) session, or nil.
|
||||
func (db *DB) SessionAdmin(sessionID string) (*Admin, error) {
|
||||
a := &Admin{}
|
||||
err := db.sql.QueryRow(
|
||||
`SELECT a.id, a.username, a.pass_hash
|
||||
a, err := scanAdmin(db.sql.QueryRow(
|
||||
`SELECT a.id, a.username, a.pass_hash, a.role, a.pw_locked
|
||||
FROM admin_sessions s JOIN admins a ON a.id = s.admin_id
|
||||
WHERE s.id = ? AND s.expires_at > ?`, sessionID, time.Now().Unix()).
|
||||
Scan(&a.ID, &a.Username, &a.PassHash)
|
||||
WHERE s.id = ? AND s.expires_at > ?`, sessionID, time.Now().Unix()))
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -222,20 +465,31 @@ type Agent struct {
|
||||
Arch string
|
||||
TokenHash string
|
||||
Version string
|
||||
RunUser string // OS user the agent process runs as (reported by the agent)
|
||||
LoginUser string // OS user terminals are opened as; "" = RunUser
|
||||
Transport string // auto, ws or lp: how the agent is to reach us
|
||||
LastSeen time.Time
|
||||
}
|
||||
|
||||
// Online reports whether the agent polled recently enough to be considered up.
|
||||
func (a Agent) Online() bool {
|
||||
// TerminalUser is the OS user a terminal on this agent runs as. Agents are
|
||||
// installed as a root systemd service, so that is the fallback for agents too
|
||||
// old to report their user.
|
||||
func (a Agent) TerminalUser() string {
|
||||
return firstNonEmpty(a.LoginUser, a.RunUser, "root")
|
||||
}
|
||||
|
||||
// recentlySeen is the fallback for "online" right after a server start, while
|
||||
// the hub has not seen the agents' polls yet (see Server.online).
|
||||
func (a Agent) recentlySeen() bool {
|
||||
return time.Since(a.LastSeen) < 45*time.Second
|
||||
}
|
||||
|
||||
func (db *DB) CreateAgent(a Agent) error {
|
||||
now := time.Now().Unix()
|
||||
_, err := db.sql.Exec(
|
||||
`INSERT INTO agents(id, name, hostname, os, arch, token_hash, version, enrolled_at, last_seen)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
a.ID, a.Name, a.Hostname, a.OS, a.Arch, a.TokenHash, a.Version, now, now)
|
||||
`INSERT INTO agents(id, name, hostname, os, arch, token_hash, version, run_user, enrolled_at, last_seen)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
a.ID, a.Name, a.Hostname, a.OS, a.Arch, a.TokenHash, a.Version, a.RunUser, now, now)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -243,8 +497,8 @@ func (db *DB) GetAgent(id string) (*Agent, error) {
|
||||
a := &Agent{}
|
||||
var last int64
|
||||
err := db.sql.QueryRow(
|
||||
`SELECT id, name, hostname, os, arch, token_hash, version, last_seen FROM agents WHERE id = ?`, id).
|
||||
Scan(&a.ID, &a.Name, &a.Hostname, &a.OS, &a.Arch, &a.TokenHash, &a.Version, &last)
|
||||
`SELECT id, name, hostname, os, arch, token_hash, version, run_user, login_user, transport, last_seen FROM agents WHERE id = ?`, id).
|
||||
Scan(&a.ID, &a.Name, &a.Hostname, &a.OS, &a.Arch, &a.TokenHash, &a.Version, &a.RunUser, &a.LoginUser, &a.Transport, &last)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -257,7 +511,7 @@ func (db *DB) GetAgent(id string) (*Agent, error) {
|
||||
|
||||
func (db *DB) ListAgents() ([]Agent, error) {
|
||||
rows, err := db.sql.Query(
|
||||
`SELECT id, name, hostname, os, arch, version, last_seen FROM agents ORDER BY name`)
|
||||
`SELECT id, name, hostname, os, arch, version, run_user, login_user, last_seen FROM agents ORDER BY name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -266,7 +520,7 @@ func (db *DB) ListAgents() ([]Agent, error) {
|
||||
for rows.Next() {
|
||||
var a Agent
|
||||
var last int64
|
||||
if err := rows.Scan(&a.ID, &a.Name, &a.Hostname, &a.OS, &a.Arch, &a.Version, &last); err != nil {
|
||||
if err := rows.Scan(&a.ID, &a.Name, &a.Hostname, &a.OS, &a.Arch, &a.Version, &a.RunUser, &a.LoginUser, &last); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.LastSeen = time.Unix(last, 0)
|
||||
@@ -275,20 +529,34 @@ func (db *DB) ListAgents() ([]Agent, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (db *DB) SetAgentTransport(id, transport string) error {
|
||||
_, err := db.sql.Exec(`UPDATE agents SET transport = ? WHERE id = ?`, transport, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) TouchAgent(id string) error {
|
||||
_, err := db.sql.Exec(`UPDATE agents SET last_seen = ? WHERE id = ?`, time.Now().Unix(), id)
|
||||
return err
|
||||
}
|
||||
|
||||
// TouchAgentVersion updates last_seen and the reported version together.
|
||||
func (db *DB) TouchAgentVersion(id, version string) error {
|
||||
_, err := db.sql.Exec(`UPDATE agents SET last_seen = ?, version = ? WHERE id = ?`,
|
||||
time.Now().Unix(), version, id)
|
||||
// TouchAgentInfo updates last_seen together with the reported version and OS user.
|
||||
func (db *DB) TouchAgentInfo(id, version, runUser string) error {
|
||||
_, err := db.sql.Exec(`UPDATE agents SET last_seen = ?, version = ?, run_user = ? WHERE id = ?`,
|
||||
time.Now().Unix(), version, runUser, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetAgentLoginUser sets the OS user terminals are opened as ("" = the agent's own).
|
||||
func (db *DB) SetAgentLoginUser(id, user string) error {
|
||||
_, err := db.sql.Exec(`UPDATE agents SET login_user = ? WHERE id = ?`, user, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) DeleteAgent(id string) error {
|
||||
_, err := db.sql.Exec(`DELETE FROM agents WHERE id = ?`, id)
|
||||
if err == nil {
|
||||
_, _ = db.sql.Exec(`DELETE FROM admin_agents WHERE agent_id = ?`, id)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -308,6 +576,8 @@ type ForwardRule struct {
|
||||
HostPort int
|
||||
ListenAddr string
|
||||
Proto string
|
||||
NoUfw bool // the panel opens no ufw rule for this forward
|
||||
Label string // optional note shown next to the rule
|
||||
}
|
||||
|
||||
func (db *DB) CreateForwardRule(r ForwardRule) (int64, error) {
|
||||
@@ -315,9 +585,9 @@ func (db *DB) CreateForwardRule(r ForwardRule) (int64, error) {
|
||||
r.Proto = "http"
|
||||
}
|
||||
res, err := db.sql.Exec(
|
||||
`INSERT INTO forward_rules(agent_id, client_port, host_port, listen_addr, proto, created_at)
|
||||
VALUES(?, ?, ?, ?, ?, ?)`,
|
||||
r.AgentID, r.ClientPort, r.HostPort, r.ListenAddr, r.Proto, time.Now().Unix())
|
||||
`INSERT INTO forward_rules(agent_id, client_port, host_port, listen_addr, proto, no_ufw, label, created_at)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
r.AgentID, r.ClientPort, r.HostPort, r.ListenAddr, r.Proto, r.NoUfw, r.Label, time.Now().Unix())
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -326,7 +596,7 @@ func (db *DB) CreateForwardRule(r ForwardRule) (int64, error) {
|
||||
|
||||
func (db *DB) ListForwardRules() ([]ForwardRule, error) {
|
||||
rows, err := db.sql.Query(
|
||||
`SELECT id, agent_id, client_port, host_port, listen_addr, proto FROM forward_rules ORDER BY id`)
|
||||
`SELECT id, agent_id, client_port, host_port, listen_addr, proto, no_ufw, label FROM forward_rules ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -334,7 +604,7 @@ func (db *DB) ListForwardRules() ([]ForwardRule, error) {
|
||||
var out []ForwardRule
|
||||
for rows.Next() {
|
||||
var r ForwardRule
|
||||
if err := rows.Scan(&r.ID, &r.AgentID, &r.ClientPort, &r.HostPort, &r.ListenAddr, &r.Proto); err != nil {
|
||||
if err := rows.Scan(&r.ID, &r.AgentID, &r.ClientPort, &r.HostPort, &r.ListenAddr, &r.Proto, &r.NoUfw, &r.Label); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, r)
|
||||
@@ -359,8 +629,8 @@ func (db *DB) ListForwardRulesByAgent(agentID string) ([]ForwardRule, error) {
|
||||
func (db *DB) GetForwardRule(id int64) (*ForwardRule, error) {
|
||||
r := &ForwardRule{}
|
||||
err := db.sql.QueryRow(
|
||||
`SELECT id, agent_id, client_port, host_port, listen_addr, proto FROM forward_rules WHERE id = ?`, id).
|
||||
Scan(&r.ID, &r.AgentID, &r.ClientPort, &r.HostPort, &r.ListenAddr, &r.Proto)
|
||||
`SELECT id, agent_id, client_port, host_port, listen_addr, proto, no_ufw, label FROM forward_rules WHERE id = ?`, id).
|
||||
Scan(&r.ID, &r.AgentID, &r.ClientPort, &r.HostPort, &r.ListenAddr, &r.Proto, &r.NoUfw, &r.Label)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -378,7 +648,20 @@ type UfwRule struct {
|
||||
ID int64
|
||||
Port int
|
||||
Proto string
|
||||
RuleID int64 // bound forward rule id, or 0 for shared/manual (e.g. tunnel 8446)
|
||||
RuleID int64 // bound forward rule id, 0 for shared (the tunnel port), ufwManual for one added in Settings
|
||||
}
|
||||
|
||||
// ufwManual is the RuleID of a ufw rule the admin added by hand: no forward
|
||||
// owns it, so only the admin removes it.
|
||||
const ufwManual = -1
|
||||
|
||||
// Manual reports whether the admin added the rule by hand.
|
||||
func (r UfwRule) Manual() bool { return r.RuleID == ufwManual }
|
||||
|
||||
// SetUfwRuleManual hands a tracked rule over to the admin.
|
||||
func (db *DB) SetUfwRuleManual(port int, proto string) error {
|
||||
_, err := db.sql.Exec(`UPDATE ufw_rules SET rule_id = ? WHERE port = ? AND proto = ?`, ufwManual, port, proto)
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) AddUfwRule(port int, proto string, ruleID int64) error {
|
||||
@@ -450,3 +733,10 @@ func (db *DB) Audit(adminID int64, agentID, action string) {
|
||||
`INSERT INTO audit(admin_id, agent_id, action, at) VALUES(?, ?, ?, ?)`,
|
||||
adminID, agentID, action, time.Now().Unix())
|
||||
}
|
||||
|
||||
func boolInt(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -14,8 +14,8 @@ import (
|
||||
// supports three transports:
|
||||
// - "http": tunneled over the HTTP long-poll channel (works anywhere nginx
|
||||
// reaches, highest latency);
|
||||
// - "tcp": direct TCP multiplexed over the 8446 tunnel (low latency);
|
||||
// - "udp": direct UDP over the 8446 channel.
|
||||
// - "tcp": direct TCP multiplexed over the tunnel port (low latency);
|
||||
// - "udp": direct UDP over the tunnel port.
|
||||
type forwardManager struct {
|
||||
hub *Hub
|
||||
tun *tunnelHub
|
||||
@@ -92,11 +92,11 @@ func (m *forwardManager) acceptTCP(rule ForwardRule, ln net.Listener) {
|
||||
}
|
||||
}
|
||||
|
||||
// bridgeDirect wires a connection to the agent's low-latency 8446 tunnel.
|
||||
// bridgeDirect wires a connection to the agent's low-latency direct tunnel.
|
||||
func (m *forwardManager) bridgeDirect(rule ForwardRule, conn net.Conn) {
|
||||
at := m.tun.get(rule.AgentID)
|
||||
if at == nil {
|
||||
// No direct tunnel (agent offline or 8446 unreachable).
|
||||
// No direct tunnel (agent offline or the tunnel port unreachable).
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,623 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||
)
|
||||
|
||||
// Files go between the browser and the agent in pieces. Each piece is a
|
||||
// request of its own, small enough for any proxy in front (Cloudflare takes
|
||||
// 100 MB a request at most), and one that fails is sent again instead of the
|
||||
// whole file. Several pieces are in flight at once, so a good link stays as
|
||||
// busy as it would with one long stream. The server holds only a few pieces
|
||||
// of a transfer: whoever sends waits while they wait to be taken.
|
||||
//
|
||||
// A download keeps one plain response towards the browser, which a proxy
|
||||
// passes on whatever its size: links, previews and the hex editor read it.
|
||||
|
||||
const (
|
||||
// xferChunk is the size of a piece.
|
||||
xferChunk = 4 << 20
|
||||
// xferBuffer is how many pieces of a transfer the server holds.
|
||||
xferBuffer = 8
|
||||
// xferWait is how long a request waits for its piece, or for room for it,
|
||||
// before it is told to ask again: well under a proxy's timeout.
|
||||
xferWait = 50 * time.Second
|
||||
// xferIdle ends a transfer nobody has touched for this long.
|
||||
xferIdle = 10 * time.Minute
|
||||
// xferTimeout bounds one transfer, however slow the links.
|
||||
xferTimeout = 2 * time.Hour
|
||||
)
|
||||
|
||||
// fsLegacyCap is the most an agent that predates the pieces can take in or
|
||||
// hand over: the file goes base64 inside a task then.
|
||||
const fsLegacyCap = 32 << 20
|
||||
|
||||
// xfer is one file on its way.
|
||||
type xfer struct {
|
||||
id, agentID string
|
||||
|
||||
mu sync.Mutex
|
||||
changed chan struct{} // closed and replaced on every change
|
||||
size int64 // -1 until known
|
||||
n int // pieces in all, once the size is known
|
||||
chunks map[int][]byte
|
||||
// low is the first piece still to go: the ones below it were written by
|
||||
// the agent (upload) or to the browser (download).
|
||||
low int
|
||||
sized chan struct{} // closed once the size is known
|
||||
err string // why the transfer failed
|
||||
done bool
|
||||
result proto.TaskResult
|
||||
last time.Time
|
||||
}
|
||||
|
||||
func pieces(size int64) int {
|
||||
if size <= 0 {
|
||||
return 1 // an empty file is one empty piece
|
||||
}
|
||||
return int((size + xferChunk - 1) / xferChunk)
|
||||
}
|
||||
|
||||
// pieceLen is how long piece i of the file is.
|
||||
func (x *xfer) pieceLen(i int) int64 {
|
||||
return min(int64(xferChunk), x.size-int64(i)*xferChunk)
|
||||
}
|
||||
|
||||
// notify wakes whoever waits on the transfer; x.mu is held.
|
||||
func (x *xfer) notify() {
|
||||
x.last = time.Now()
|
||||
close(x.changed)
|
||||
x.changed = make(chan struct{})
|
||||
}
|
||||
|
||||
// await waits until ready() holds and returns true with x.mu held; false,
|
||||
// with it free, when the wait ran out or ctx ended.
|
||||
func (x *xfer) await(ctx context.Context, d time.Duration, ready func() bool) bool {
|
||||
t := time.NewTimer(d)
|
||||
defer t.Stop()
|
||||
for {
|
||||
x.mu.Lock()
|
||||
if ready() {
|
||||
return true
|
||||
}
|
||||
ch := x.changed
|
||||
x.mu.Unlock()
|
||||
select {
|
||||
case <-ch:
|
||||
case <-t.C:
|
||||
return false
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (x *xfer) fail(msg string) {
|
||||
x.mu.Lock()
|
||||
if x.err == "" && !x.done {
|
||||
x.err = msg
|
||||
x.chunks = nil
|
||||
x.notify()
|
||||
}
|
||||
x.mu.Unlock()
|
||||
}
|
||||
|
||||
func (x *xfer) finish(res proto.TaskResult) {
|
||||
x.mu.Lock()
|
||||
x.done, x.result, x.chunks = true, res, nil
|
||||
x.notify()
|
||||
x.mu.Unlock()
|
||||
}
|
||||
|
||||
// setSize records the file's size; false when it differs from the one known.
|
||||
func (x *xfer) setSize(size int64) bool {
|
||||
x.mu.Lock()
|
||||
defer x.mu.Unlock()
|
||||
if x.size >= 0 {
|
||||
return x.size == size
|
||||
}
|
||||
x.size, x.n = size, pieces(size)
|
||||
close(x.sized)
|
||||
x.notify()
|
||||
return true
|
||||
}
|
||||
|
||||
// put stores piece i once there is room for it, and answers with the HTTP
|
||||
// status for its sender. A piece already taken or held is accepted again: it
|
||||
// is a retry.
|
||||
func (x *xfer) put(ctx context.Context, i int, data []byte) (int, string) {
|
||||
if !x.await(ctx, xferWait, func() bool { return x.err != "" || x.done || i < x.low+xferBuffer }) {
|
||||
return http.StatusServiceUnavailable, "нет места, повторите"
|
||||
}
|
||||
defer x.mu.Unlock()
|
||||
if x.err != "" || x.done {
|
||||
return http.StatusGone, x.gone()
|
||||
}
|
||||
if i < 0 || i >= x.n || int64(len(data)) != x.pieceLen(i) {
|
||||
return http.StatusBadRequest, "не тот кусок"
|
||||
}
|
||||
if i >= x.low && x.chunks[i] == nil {
|
||||
x.chunks[i] = data
|
||||
x.notify()
|
||||
}
|
||||
return http.StatusNoContent, ""
|
||||
}
|
||||
|
||||
// take hands over piece i, the next one in order, and lets it go.
|
||||
func (x *xfer) take(ctx context.Context, i int, d time.Duration) ([]byte, error) {
|
||||
if !x.await(ctx, d, func() bool { return x.err != "" || x.chunks[i] != nil }) {
|
||||
return nil, errors.New("кусок не пришёл вовремя")
|
||||
}
|
||||
defer x.mu.Unlock()
|
||||
if x.err != "" {
|
||||
return nil, errors.New(x.err)
|
||||
}
|
||||
data := x.chunks[i]
|
||||
delete(x.chunks, i)
|
||||
x.low = i + 1
|
||||
x.notify()
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (x *xfer) gone() string {
|
||||
if x.err != "" {
|
||||
return x.err
|
||||
}
|
||||
return "передача закончена"
|
||||
}
|
||||
|
||||
// Read makes an upload a plain reader, for an agent that takes a file whole.
|
||||
type xferReader struct {
|
||||
ctx context.Context
|
||||
x *xfer
|
||||
i int
|
||||
buf []byte
|
||||
}
|
||||
|
||||
func (r *xferReader) Read(p []byte) (int, error) {
|
||||
for len(r.buf) == 0 {
|
||||
if r.i >= r.x.n {
|
||||
return 0, io.EOF
|
||||
}
|
||||
data, err := r.x.take(r.ctx, r.i, xferIdle)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
r.buf, r.i = data, r.i+1
|
||||
}
|
||||
n := copy(p, r.buf)
|
||||
r.buf = r.buf[n:]
|
||||
return n, nil
|
||||
}
|
||||
|
||||
type xfers struct {
|
||||
mu sync.Mutex
|
||||
m map[string]*xfer
|
||||
}
|
||||
|
||||
func (c *xfers) open(agentID string, size int64) *xfer {
|
||||
x := &xfer{
|
||||
id: randToken()[:24], agentID: agentID, size: -1,
|
||||
changed: make(chan struct{}), chunks: map[int][]byte{},
|
||||
sized: make(chan struct{}), last: time.Now(),
|
||||
}
|
||||
if size >= 0 {
|
||||
x.size, x.n = size, pieces(size)
|
||||
close(x.sized)
|
||||
}
|
||||
c.mu.Lock()
|
||||
if c.m == nil {
|
||||
c.m = map[string]*xfer{}
|
||||
}
|
||||
// Forget the ones left behind: a closed tab, a client gone for good.
|
||||
for id, old := range c.m {
|
||||
old.mu.Lock()
|
||||
stale := time.Since(old.last) > xferIdle
|
||||
old.mu.Unlock()
|
||||
if stale {
|
||||
old.fail("передача брошена")
|
||||
delete(c.m, id)
|
||||
}
|
||||
}
|
||||
c.m[x.id] = x
|
||||
c.mu.Unlock()
|
||||
return x
|
||||
}
|
||||
|
||||
func (c *xfers) get(id string) *xfer {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.m[id]
|
||||
}
|
||||
|
||||
// forAgent returns the transfer if it is the asking agent's.
|
||||
func (c *xfers) forAgent(id, agentID string) *xfer {
|
||||
if x := c.get(id); x != nil && x.agentID == agentID {
|
||||
return x
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *xfers) drop(id string) {
|
||||
c.mu.Lock()
|
||||
delete(c.m, id)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// waitTask runs a task on the agent and waits for its result, or for ctx to
|
||||
// end.
|
||||
func (s *Server) waitTask(ctx context.Context, agentID, typ string, args any, timeout time.Duration) (proto.TaskResult, error) {
|
||||
raw, _ := json.Marshal(args)
|
||||
taskID, ch := s.hub.CreateTask(agentID, typ, raw)
|
||||
defer s.hub.DiscardTask(taskID)
|
||||
t := time.NewTimer(timeout)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case res := <-ch:
|
||||
return res, nil
|
||||
case <-ctx.Done():
|
||||
return proto.TaskResult{}, ctx.Err()
|
||||
case <-t.C:
|
||||
return proto.TaskResult{}, errors.New("таймаут ожидания агента")
|
||||
}
|
||||
}
|
||||
|
||||
// unknownOp tells an agent that predates the task.
|
||||
func unknownOp(res proto.TaskResult) bool {
|
||||
return !res.OK && strings.Contains(res.Error, "неизвестная операция")
|
||||
}
|
||||
|
||||
// fsAgent answers the request itself and returns false when the agent is
|
||||
// unknown or offline.
|
||||
func (s *Server) fsAgent(w http.ResponseWriter, agentID string, asJSON bool) bool {
|
||||
a, err := s.db.GetAgent(agentID)
|
||||
if err != nil || a == nil {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return false
|
||||
}
|
||||
if !s.online(a) {
|
||||
if asJSON {
|
||||
writeJSON(w, proto.TaskResult{Error: "машина офлайн"})
|
||||
} else {
|
||||
http.Error(w, "машина офлайн", http.StatusServiceUnavailable)
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// readPiece reads a piece's body into a buffer of its own size, so pieces
|
||||
// leave no trail of grown buffers behind.
|
||||
func readPiece(w http.ResponseWriter, r *http.Request) ([]byte, error) {
|
||||
if r.ContentLength >= 0 && r.ContentLength <= xferChunk {
|
||||
data := make([]byte, r.ContentLength)
|
||||
_, err := io.ReadFull(r.Body, data)
|
||||
return data, err
|
||||
}
|
||||
return io.ReadAll(http.MaxBytesReader(w, r.Body, xferChunk))
|
||||
}
|
||||
|
||||
// ---------- upload: browser -> agent ----------
|
||||
|
||||
// POST /api/panel/agents/{id}/fs/upload?path=...&size=N - start writing a
|
||||
// file. The answer names the transfer and the piece size; the browser then
|
||||
// PUTs the pieces and asks for the result.
|
||||
func (s *Server) handleFSUploadStart(w http.ResponseWriter, r *http.Request) {
|
||||
agentID, p := r.PathValue("id"), r.URL.Query().Get("path")
|
||||
if !s.fsAgent(w, agentID, true) {
|
||||
return
|
||||
}
|
||||
size, err := strconv.ParseInt(r.URL.Query().Get("size"), 10, 64)
|
||||
if p == "" || err != nil || size < 0 {
|
||||
writeJSON(w, proto.TaskResult{Error: "не указан путь или размер"})
|
||||
return
|
||||
}
|
||||
if size > proto.FSFileCap {
|
||||
writeJSON(w, proto.TaskResult{Error: "файл больше 512 МБ"})
|
||||
return
|
||||
}
|
||||
x := s.xfers.open(agentID, size)
|
||||
go s.runUpload(x, p)
|
||||
data, _ := json.Marshal(map[string]any{"id": x.id, "chunk": xferChunk})
|
||||
writeJSON(w, proto.TaskResult{OK: true, Data: data})
|
||||
}
|
||||
|
||||
// runUpload has the agent write the file and keeps the result for the
|
||||
// browser to collect.
|
||||
func (s *Server) runUpload(x *xfer, p string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), xferTimeout)
|
||||
defer cancel()
|
||||
args := proto.FSXferArgs{Path: p, Xfer: x.id, Size: x.size, Chunk: xferChunk}
|
||||
res, err := s.waitTask(ctx, x.agentID, proto.TaskFSPut, args, xferTimeout)
|
||||
if err == nil && unknownOp(res) {
|
||||
res = s.legacyUpload(ctx, x, p)
|
||||
}
|
||||
if err != nil {
|
||||
res = proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
if !res.OK {
|
||||
x.fail(res.Error)
|
||||
}
|
||||
x.finish(res)
|
||||
// The result waits a while for the browser to come for it.
|
||||
time.AfterFunc(xferIdle, func() { s.xfers.drop(x.id) })
|
||||
}
|
||||
|
||||
// legacyUpload writes a file through an agent that predates the pieces.
|
||||
func (s *Server) legacyUpload(ctx context.Context, x *xfer, p string) proto.TaskResult {
|
||||
if x.size > fsLegacyCap {
|
||||
return proto.TaskResult{Error: "клиент принимает файлы до 32 МБ - обновите его"}
|
||||
}
|
||||
data, err := io.ReadAll(&xferReader{ctx: ctx, x: x})
|
||||
if err != nil {
|
||||
return proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
args := proto.FSWriteArgs{Path: p, Data: base64.StdEncoding.EncodeToString(data)}
|
||||
res, err := s.waitTask(ctx, x.agentID, proto.TaskFSWrite, args, fsTaskTimeout)
|
||||
if err != nil {
|
||||
return proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// PUT /api/panel/fs/xfer/{xid}/{i} - piece i of a file being uploaded. 503:
|
||||
// no room yet, send it again; 410: the transfer is over.
|
||||
func (s *Server) handleXferPut(w http.ResponseWriter, r *http.Request) {
|
||||
x := s.xfers.get(r.PathValue("xid"))
|
||||
i, err := strconv.Atoi(r.PathValue("i"))
|
||||
if x == nil || err != nil {
|
||||
http.Error(w, "передача не найдена", http.StatusGone)
|
||||
return
|
||||
}
|
||||
data, err := readPiece(w, r)
|
||||
if err != nil {
|
||||
http.Error(w, "кусок не дошёл", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
code, msg := x.put(r.Context(), i, data)
|
||||
if code == http.StatusNoContent {
|
||||
w.WriteHeader(code)
|
||||
return
|
||||
}
|
||||
http.Error(w, msg, code)
|
||||
}
|
||||
|
||||
// POST /api/panel/fs/xfer/{xid}/cancel - the browser gives the upload up.
|
||||
func (s *Server) handleXferCancel(w http.ResponseWriter, r *http.Request) {
|
||||
if x := s.xfers.get(r.PathValue("xid")); x != nil {
|
||||
x.fail("отменено")
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// GET /api/panel/fs/xfer/{xid}/result - the upload's result, once the agent
|
||||
// has written the file; {"done":false} means ask again.
|
||||
func (s *Server) handleXferResult(w http.ResponseWriter, r *http.Request) {
|
||||
x := s.xfers.get(r.PathValue("xid"))
|
||||
if x == nil {
|
||||
writeJSON(w, map[string]any{"done": true, "result": proto.TaskResult{Error: "передача не найдена"}})
|
||||
return
|
||||
}
|
||||
if !x.await(r.Context(), xferWait, func() bool { return x.done }) {
|
||||
writeJSON(w, map[string]any{"done": false})
|
||||
return
|
||||
}
|
||||
res := x.result
|
||||
x.mu.Unlock()
|
||||
writeJSON(w, map[string]any{"done": true, "result": res})
|
||||
}
|
||||
|
||||
// GET /api/agent/fs/xfer/{xid}/{i}?ack=K - piece i of the upload, for the
|
||||
// agent to write. K says the agent has written every piece below it, so
|
||||
// those are let go. 503: not here yet, ask again; 410: the transfer is over.
|
||||
func (s *Server) handleAgentXferGet(w http.ResponseWriter, r *http.Request) {
|
||||
a := s.authenticateAgent(r)
|
||||
if a == nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
x := s.xfers.forAgent(r.PathValue("xid"), a.ID)
|
||||
i, err := strconv.Atoi(r.PathValue("i"))
|
||||
if x == nil || err != nil {
|
||||
http.Error(w, "передача не найдена", http.StatusGone)
|
||||
return
|
||||
}
|
||||
if ack, err := strconv.Atoi(r.URL.Query().Get("ack")); err == nil {
|
||||
x.mu.Lock()
|
||||
if ack > x.low {
|
||||
for k := x.low; k < ack; k++ {
|
||||
delete(x.chunks, k)
|
||||
}
|
||||
x.low = ack
|
||||
x.notify()
|
||||
}
|
||||
x.mu.Unlock()
|
||||
}
|
||||
if !x.await(r.Context(), xferWait, func() bool { return x.err != "" || x.chunks[i] != nil }) {
|
||||
http.Error(w, "кусок ещё не пришёл", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if x.err != "" {
|
||||
msg := x.err
|
||||
x.mu.Unlock()
|
||||
http.Error(w, msg, http.StatusGone)
|
||||
return
|
||||
}
|
||||
data := x.chunks[i]
|
||||
x.mu.Unlock()
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
// ---------- download: agent -> browser ----------
|
||||
|
||||
// inlineTypes are the files the panel shows in place rather than downloads.
|
||||
var inlineTypes = map[string]string{
|
||||
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif",
|
||||
".webp": "image/webp", ".bmp": "image/bmp", ".ico": "image/x-icon", ".avif": "image/avif",
|
||||
".svg": "image/svg+xml",
|
||||
".mp4": "video/mp4", ".m4v": "video/mp4", ".webm": "video/webm", ".ogv": "video/ogg", ".mov": "video/quicktime",
|
||||
".mp3": "audio/mpeg", ".wav": "audio/wav", ".ogg": "audio/ogg", ".oga": "audio/ogg", ".m4a": "audio/mp4",
|
||||
".aac": "audio/aac", ".flac": "audio/flac", ".opus": "audio/ogg",
|
||||
".pdf": "application/pdf",
|
||||
}
|
||||
|
||||
// fileHeaders sets what the browser needs to download or show the file. A
|
||||
// file shown in place runs nothing: no scripts in an SVG, whatever it holds.
|
||||
// A PDF is the exception, as the browser's viewer will not open in a sandbox;
|
||||
// it runs in that viewer, not in the panel's page.
|
||||
func fileHeaders(w http.ResponseWriter, p string, inline bool) {
|
||||
name := path.Base(p)
|
||||
ctype := "application/octet-stream"
|
||||
if t, ok := inlineTypes[strings.ToLower(path.Ext(name))]; ok && inline {
|
||||
ctype = t
|
||||
}
|
||||
disp := "attachment"
|
||||
if inline {
|
||||
disp = "inline"
|
||||
}
|
||||
w.Header().Set("Content-Type", ctype)
|
||||
w.Header().Set("Content-Disposition", disp+"; filename=\""+strings.ReplaceAll(name, "\"", "")+"\"")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
if ctype != "application/pdf" {
|
||||
w.Header().Set("Content-Security-Policy", "sandbox; default-src 'none'; img-src data:; style-src 'unsafe-inline'")
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
}
|
||||
|
||||
// GET /api/panel/agents/{id}/fs/download?path=...[&inline=1] - the file, as
|
||||
// one response fed by the pieces the agent sends. inline=1 serves pictures,
|
||||
// video and sound for the page to show.
|
||||
func (s *Server) handleFSDownload(w http.ResponseWriter, r *http.Request) {
|
||||
agentID, p := r.PathValue("id"), r.URL.Query().Get("path")
|
||||
inline := r.URL.Query().Get("inline") != ""
|
||||
if !s.fsAgent(w, agentID, false) {
|
||||
return
|
||||
}
|
||||
x := s.xfers.open(agentID, -1)
|
||||
defer s.xfers.drop(x.id)
|
||||
defer x.fail("браузер ушёл") // stops the agent if the response ends early
|
||||
|
||||
raw, _ := json.Marshal(proto.FSXferArgs{Path: p, Xfer: x.id, Size: -1, Chunk: xferChunk})
|
||||
taskID, ch := s.hub.CreateTask(agentID, proto.TaskFSSend, raw)
|
||||
defer s.hub.DiscardTask(taskID)
|
||||
// The agent's result: an error ends the transfer wherever it stands.
|
||||
results := make(chan proto.TaskResult, 1)
|
||||
go func() {
|
||||
select {
|
||||
case res := <-ch:
|
||||
if !res.OK && !unknownOp(res) {
|
||||
x.fail(res.Error)
|
||||
}
|
||||
results <- res
|
||||
case <-r.Context().Done():
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-x.sized:
|
||||
case res := <-results:
|
||||
if unknownOp(res) {
|
||||
s.legacyDownload(w, r, agentID, p, inline)
|
||||
return
|
||||
}
|
||||
msg := res.Error
|
||||
if msg == "" {
|
||||
msg = "агент не отдал файл"
|
||||
}
|
||||
http.Error(w, msg, http.StatusBadGateway)
|
||||
return
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case <-time.After(fsTaskTimeout):
|
||||
http.Error(w, "таймаут ожидания агента", http.StatusGatewayTimeout)
|
||||
return
|
||||
}
|
||||
|
||||
fileHeaders(w, p, inline)
|
||||
w.Header().Set("Content-Length", strconv.FormatInt(x.size, 10))
|
||||
rc := http.NewResponseController(w)
|
||||
for i := 0; i < x.n; i++ {
|
||||
data, err := x.take(r.Context(), i, xferIdle)
|
||||
if err == nil {
|
||||
_, err = w.Write(data)
|
||||
}
|
||||
if err != nil {
|
||||
// Cut the response off, so the browser does not take a part of
|
||||
// the file for all of it.
|
||||
x.fail(err.Error())
|
||||
panic(http.ErrAbortHandler)
|
||||
}
|
||||
_ = rc.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
// legacyDownload gets a file from an agent that predates the pieces.
|
||||
func (s *Server) legacyDownload(w http.ResponseWriter, r *http.Request, agentID, p string, inline bool) {
|
||||
res, err := s.waitTask(r.Context(), agentID, proto.TaskFSDownload, proto.FSPathArgs{Path: p}, fsTaskTimeout)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusGatewayTimeout)
|
||||
return
|
||||
}
|
||||
if !res.OK {
|
||||
http.Error(w, res.Error, http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
var rr proto.FSReadResult
|
||||
if err := json.Unmarshal(res.Data, &rr); err != nil {
|
||||
http.Error(w, "bad result", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
data, _ := base64.StdEncoding.DecodeString(rr.Content)
|
||||
fileHeaders(w, p, inline)
|
||||
if rr.Truncated {
|
||||
w.Header().Set("X-Truncated", "1")
|
||||
}
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
// POST /api/agent/fs/xfer/{xid}/{i} - piece i of the file the browser asked
|
||||
// for, X-Size its whole size. 503: no room yet, send it again; 410: the
|
||||
// browser is gone.
|
||||
func (s *Server) handleAgentXferPost(w http.ResponseWriter, r *http.Request) {
|
||||
a := s.authenticateAgent(r)
|
||||
if a == nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
x := s.xfers.forAgent(r.PathValue("xid"), a.ID)
|
||||
i, err := strconv.Atoi(r.PathValue("i"))
|
||||
if x == nil || err != nil {
|
||||
http.Error(w, "передача не найдена", http.StatusGone)
|
||||
return
|
||||
}
|
||||
size, err := strconv.ParseInt(r.Header.Get("X-Size"), 10, 64)
|
||||
if err != nil || size < 0 || size > proto.FSFileCap || !x.setSize(size) {
|
||||
http.Error(w, "не тот размер", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
data, err := readPiece(w, r)
|
||||
if err != nil {
|
||||
http.Error(w, "кусок не дошёл", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
code, msg := x.put(r.Context(), i, data)
|
||||
if code == http.StatusNoContent {
|
||||
w.WriteHeader(code)
|
||||
return
|
||||
}
|
||||
http.Error(w, msg, code)
|
||||
}
|
||||
@@ -16,7 +16,7 @@ func writeJSON(w http.ResponseWriter, v any) {
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
// POST /api/agent/enroll — exchange the shared enroll secret for a per-agent id+token.
|
||||
// POST /api/agent/enroll - exchange the shared enroll secret for a per-agent id+token.
|
||||
func (s *Server) handleEnroll(w http.ResponseWriter, r *http.Request) {
|
||||
secret := r.Header.Get("X-Enroll-Secret")
|
||||
if !constantTimeEqual(secret, s.db.EnrollSecret()) {
|
||||
@@ -28,7 +28,7 @@ func (s *Server) handleEnroll(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
id, token, err := s.enroll(req.Name, req.Hostname, req.OS, req.Arch, req.Version)
|
||||
id, token, err := s.enroll(req.Name, req.Hostname, req.OS, req.Arch, req.Version, req.User)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -36,23 +36,58 @@ func (s *Server) handleEnroll(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, proto.EnrollResponse{AgentID: id, Token: token})
|
||||
}
|
||||
|
||||
// GET /api/agent/poll — control channel. Held until a job is queued or timeout.
|
||||
// GET /api/agent/poll - control channel. Held until a job is queued or timeout.
|
||||
func (s *Server) handlePoll(w http.ResponseWriter, r *http.Request) {
|
||||
a := s.authenticateAgent(r)
|
||||
if a == nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
// The agent sends its version occasionally; record it when present.
|
||||
if v := r.Header.Get("X-Agent-Version"); v != "" && v != a.Version {
|
||||
_ = s.db.TouchAgentVersion(a.ID, v)
|
||||
// The agent sends its version and OS user occasionally; record them when
|
||||
// present. Agents older than 0.0.2 send no user, so keep what we have.
|
||||
s.viaDirectA.Store(a.ID, s.viaDirect(r))
|
||||
v := r.Header.Get("X-Agent-Version")
|
||||
u := firstNonEmpty(r.Header.Get("X-Agent-User"), a.RunUser)
|
||||
if v != "" && (v != a.Version || u != a.RunUser) {
|
||||
_ = s.db.TouchAgentInfo(a.ID, v, u)
|
||||
} else {
|
||||
_ = s.db.TouchAgent(a.ID)
|
||||
}
|
||||
writeJSON(w, s.hub.WaitControl(a.ID, pollHold))
|
||||
msg := s.hub.WaitControl(r.Context().Done(), a.ID, pollHold)
|
||||
// Set after the hold so a setting changed meanwhile reaches the agent now.
|
||||
s.setTunnelHeader(w)
|
||||
w.Header().Set(proto.TransportHeader, s.transportOf(a.ID))
|
||||
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)
|
||||
}
|
||||
if d := s.db.DirectAddress(); d != "" {
|
||||
w.Header().Set(proto.FallbackHeader, d)
|
||||
w.Header().Set(proto.MainHeader, s.db.HostAddress())
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
a := s.authenticateAgent(r)
|
||||
if a == nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
s.setTunnelHeader(w)
|
||||
w.Header().Set(proto.TransportHeader, s.transportOf(a.ID))
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// GET /api/agent/task/{id} - the agent fetches the task to run.
|
||||
func (s *Server) handleTaskFetch(w http.ResponseWriter, r *http.Request) {
|
||||
if s.authenticateAgent(r) == nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
@@ -66,7 +101,7 @@ func (s *Server) handleTaskFetch(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, env)
|
||||
}
|
||||
|
||||
// POST /api/agent/task/{id}/result — the agent returns the task result.
|
||||
// POST /api/agent/task/{id}/result - the agent returns the task result.
|
||||
func (s *Server) handleTaskResult(w http.ResponseWriter, r *http.Request) {
|
||||
if s.authenticateAgent(r) == nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
@@ -81,7 +116,7 @@ func (s *Server) handleTaskResult(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// GET /api/agent/term/{sid}/in — downstream (panel -> agent) long-poll.
|
||||
// GET /api/agent/term/{sid}/in - downstream (panel -> agent) long-poll.
|
||||
func (s *Server) handleTermIn(w http.ResponseWriter, r *http.Request) {
|
||||
if s.authenticateAgent(r) == nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
@@ -100,7 +135,7 @@ func (s *Server) handleTermIn(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, proto.InBatch{Frames: frames})
|
||||
}
|
||||
|
||||
// POST /api/agent/term/{sid}/out — upstream (agent PTY -> panel).
|
||||
// POST /api/agent/term/{sid}/out - upstream (agent PTY -> panel).
|
||||
func (s *Server) handleTermOut(w http.ResponseWriter, r *http.Request) {
|
||||
if s.authenticateAgent(r) == nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
@@ -121,7 +156,11 @@ func (s *Server) handleTermOut(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if msg.Exit {
|
||||
s.hub.CloseSession(sess.ID)
|
||||
// End the stream now, but keep the session reachable for a moment so
|
||||
// the panel can still collect the last output (e.g. why the shell
|
||||
// failed to start).
|
||||
sess.close()
|
||||
time.AfterFunc(pollHold, func() { s.hub.CloseSession(sess.ID) })
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -1,11 +1,17 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||
@@ -82,7 +88,12 @@ func (s *Server) handleLoginSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
admin, _ := s.db.AdminByUsername(username)
|
||||
if admin == nil || !checkPassword(admin.PassHash, pw) {
|
||||
s.limiter.fin(ip, false)
|
||||
s.render(w, "login.html", map[string]any{"Error": "Неверный логин или пароль."})
|
||||
s.render(w, "login.html", map[string]any{"Error": "Неверный логин или пароль.", "Username": username})
|
||||
return
|
||||
}
|
||||
// An admin who turned the password off signs in with a passkey only.
|
||||
if s.db.PasswordOff(admin.ID) {
|
||||
s.render(w, "login.html", map[string]any{"Error": "Для этого аккаунта вход только по ключу доступа.", "Username": username})
|
||||
return
|
||||
}
|
||||
s.limiter.fin(ip, true)
|
||||
@@ -111,17 +122,60 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
Online bool
|
||||
Seen string
|
||||
}
|
||||
me := s.currentAdmin(r)
|
||||
rows := make([]row, 0, len(agents))
|
||||
for _, a := range agents {
|
||||
rows = append(rows, row{Agent: a, Online: a.Online(), Seen: humanSince(a.LastSeen)})
|
||||
if !s.db.CanUseAgent(me, a.ID) {
|
||||
continue
|
||||
}
|
||||
on := s.online(&a)
|
||||
rows = append(rows, row{Agent: a, Online: on, Seen: seenText(on, a.LastSeen)})
|
||||
}
|
||||
s.render(w, "dashboard.html", map[string]any{
|
||||
"Admin": s.currentAdmin(r),
|
||||
"Agents": rows,
|
||||
"CSRF": csrfToken(r),
|
||||
"Admin": me,
|
||||
"IsAdmin": me.IsAdmin(),
|
||||
"Agents": rows,
|
||||
"CSRF": csrfToken(r),
|
||||
})
|
||||
}
|
||||
|
||||
// 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"`
|
||||
Name string `json:"name"`
|
||||
Hostname string `json:"hostname"`
|
||||
User string `json:"user"` // who a terminal opens as
|
||||
Online bool `json:"online"`
|
||||
Seen string `json:"seen"`
|
||||
}
|
||||
me := s.currentAdmin(r)
|
||||
out := make([]item, 0, len(agents))
|
||||
for _, a := range agents {
|
||||
if !s.db.CanUseAgent(me, a.ID) {
|
||||
continue
|
||||
}
|
||||
on := s.online(&a)
|
||||
out = append(out, item{ID: a.ID, Name: a.Name, Hostname: a.Hostname, User: a.TerminalUser(), Online: on, Seen: seenText(on, a.LastSeen)})
|
||||
}
|
||||
writeJSON(w, out)
|
||||
}
|
||||
|
||||
func (s *Server) handleTerminalPage(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
a, err := s.db.GetAgent(id)
|
||||
@@ -141,7 +195,16 @@ func (s *Server) handleAgentDelete(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
// Its forward rules go with it: left behind they would keep their host
|
||||
// ports bound and busy with no page to remove them from.
|
||||
rules, _ := s.db.ListForwardRulesByAgent(id)
|
||||
for _, rule := range rules {
|
||||
s.removeForward(rule.ID)
|
||||
}
|
||||
_ = s.db.DeleteAgent(id)
|
||||
s.stats.drop(id)
|
||||
s.relays.drop(id, nil)
|
||||
s.hub.Forget(id)
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -152,13 +215,157 @@ func (s *Server) handleAgentEdit(w http.ResponseWriter, r *http.Request) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
s.render(w, "edit.html", map[string]any{
|
||||
data := map[string]any{
|
||||
"Agent": a,
|
||||
"IsAdmin": s.currentAdmin(r).IsAdmin(),
|
||||
"CSRF": csrfToken(r),
|
||||
"ServerVersion": Version,
|
||||
"Outdated": a.Version != "" && a.Version != Version,
|
||||
"Updated": r.URL.Query().Get("updated") == "1",
|
||||
"UpdateMsg": r.URL.Query().Get("umsg"),
|
||||
"UpdateErr": r.URL.Query().Get("uperr"),
|
||||
"UserOK": r.URL.Query().Get("uok"),
|
||||
"UserErr": r.URL.Query().Get("uerr"),
|
||||
"Transport": firstNonEmpty(a.Transport, proto.TransportAuto),
|
||||
"OnWS": s.wsAgents.On(a.ID),
|
||||
"ViaDirect": viaDirectOf(&s.viaDirectA, a.ID),
|
||||
"Online": s.online(a),
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
|
||||
// POST /agents/{id}/transport - how the agent is to reach the server: auto,
|
||||
// ws or lp. A connected agent picks it up within a poll round.
|
||||
func (s *Server) handleAgentTransport(w http.ResponseWriter, r *http.Request) {
|
||||
if !checkCSRF(r) {
|
||||
http.Error(w, "bad csrf", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
id, t := r.PathValue("id"), r.FormValue("transport")
|
||||
switch t {
|
||||
case proto.TransportAuto, proto.TransportWS, proto.TransportLP:
|
||||
_ = s.db.SetAgentTransport(id, t)
|
||||
}
|
||||
http.Redirect(w, r, "/agents/"+id+"/edit", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// usersTaskTimeout bounds how long the edit page waits for the agent.
|
||||
const usersTaskTimeout = 8 * time.Second
|
||||
|
||||
// agentUsers asks the agent who it runs as and which users it can log in as.
|
||||
func (s *Server) agentUsers(agentID string) (*proto.SysUsersResult, error) {
|
||||
taskID, ch := s.hub.CreateTask(agentID, proto.TaskSysUsers, nil)
|
||||
defer s.hub.DiscardTask(taskID)
|
||||
select {
|
||||
case res := <-ch:
|
||||
if !res.OK {
|
||||
// Agents older than 0.0.2 don't know this task.
|
||||
return nil, errors.New("клиент не поддерживает выбор пользователя - обновите его")
|
||||
}
|
||||
var out proto.SysUsersResult
|
||||
if err := json.Unmarshal(res.Data, &out); err != nil {
|
||||
return nil, errors.New("некорректный ответ клиента")
|
||||
}
|
||||
return &out, nil
|
||||
case <-time.After(usersTaskTimeout):
|
||||
return nil, errors.New("таймаут ожидания клиента")
|
||||
}
|
||||
}
|
||||
|
||||
// editUserRedirect returns to the edit page with a result banner.
|
||||
func editUserRedirect(w http.ResponseWriter, r *http.Request, id, key, msg string) {
|
||||
http.Redirect(w, r, "/agents/"+id+"/edit?"+key+"="+url.QueryEscape(msg), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleAgentSetUser picks the OS user terminals on this machine run as.
|
||||
func (s *Server) handleAgentSetUser(w http.ResponseWriter, r *http.Request) {
|
||||
if !checkCSRF(r) {
|
||||
http.Error(w, "bad csrf", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
a, err := s.db.GetAgent(id)
|
||||
if err != nil || a == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(r.FormValue("user"))
|
||||
if !s.online(a) {
|
||||
editUserRedirect(w, r, id, "uerr", "Машина офлайн - сменить пользователя нельзя.")
|
||||
return
|
||||
}
|
||||
// Only accept a user the machine itself reports, so a typo (or an agent too
|
||||
// old to honour the choice) can't silently leave the terminal as root.
|
||||
users, err := s.agentUsers(id)
|
||||
if err != nil {
|
||||
editUserRedirect(w, r, id, "uerr", err.Error())
|
||||
return
|
||||
}
|
||||
if !slices.Contains(users.Users, name) {
|
||||
editUserRedirect(w, r, id, "uerr", "Пользователя "+name+" нет на машине.")
|
||||
return
|
||||
}
|
||||
if name == users.Current {
|
||||
name = "" // follow the agent's own user
|
||||
}
|
||||
if err := s.db.SetAgentLoginUser(id, name); err != nil {
|
||||
editUserRedirect(w, r, id, "uerr", "Не удалось сохранить.")
|
||||
return
|
||||
}
|
||||
if admin := s.currentAdmin(r); admin != nil {
|
||||
s.db.Audit(admin.ID, id, "set_user:"+firstNonEmpty(name, users.Current))
|
||||
}
|
||||
editUserRedirect(w, r, id, "uok", "Терминал будет открываться от "+firstNonEmpty(name, users.Current)+".")
|
||||
}
|
||||
|
||||
// handleAgentAddUser creates a new OS user on the machine and switches the
|
||||
// terminal to it.
|
||||
func (s *Server) handleAgentAddUser(w http.ResponseWriter, r *http.Request) {
|
||||
if !checkCSRF(r) {
|
||||
http.Error(w, "bad csrf", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
a, err := s.db.GetAgent(id)
|
||||
if err != nil || a == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if !s.online(a) {
|
||||
editUserRedirect(w, r, id, "uerr", "Машина офлайн - создать пользователя нельзя.")
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(r.FormValue("name"))
|
||||
args, _ := json.Marshal(proto.SysUserAddArgs{
|
||||
Name: name,
|
||||
Password: r.FormValue("password"),
|
||||
Sudo: r.FormValue("sudo") == "1",
|
||||
})
|
||||
taskID, ch := s.hub.CreateTask(id, proto.TaskSysUserAdd, args)
|
||||
defer s.hub.DiscardTask(taskID)
|
||||
select {
|
||||
case res := <-ch:
|
||||
if !res.OK {
|
||||
editUserRedirect(w, r, id, "uerr", "Не удалось создать пользователя: "+res.Error)
|
||||
return
|
||||
}
|
||||
case <-time.After(30 * time.Second):
|
||||
editUserRedirect(w, r, id, "uerr", "Таймаут ожидания клиента.")
|
||||
return
|
||||
}
|
||||
_ = s.db.SetAgentLoginUser(id, name)
|
||||
if admin := s.currentAdmin(r); admin != nil {
|
||||
s.db.Audit(admin.ID, id, "create_user:"+name)
|
||||
}
|
||||
editUserRedirect(w, r, id, "uok", "Пользователь "+name+" создан и выбран для терминала.")
|
||||
}
|
||||
|
||||
// handleAgentUpdate asks the agent to download the latest binary and restart.
|
||||
@@ -173,17 +380,28 @@ func (s *Server) handleAgentUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if !a.Online() {
|
||||
http.Redirect(w, r, "/agents/"+id+"/edit?updated=0", http.StatusSeeOther)
|
||||
back := func(q url.Values) { http.Redirect(w, r, "/agents/"+id+"/edit?"+q.Encode(), http.StatusSeeOther) }
|
||||
if !s.online(a) {
|
||||
back(url.Values{"uperr": {"машина не в сети"}})
|
||||
return
|
||||
}
|
||||
taskID, ch := s.hub.CreateTask(id, proto.TaskSysUpdate, nil)
|
||||
defer s.hub.DiscardTask(taskID)
|
||||
select {
|
||||
case <-ch:
|
||||
case <-time.After(30 * time.Second):
|
||||
if admin := s.currentAdmin(r); admin != nil {
|
||||
s.db.Audit(admin.ID, id, "update:"+a.Version+" -> "+Version)
|
||||
}
|
||||
// The download can take a while on a slow link; under a proxy's timeout.
|
||||
res, err := s.waitTask(r.Context(), id, proto.TaskSysUpdate, nil, 80*time.Second)
|
||||
switch {
|
||||
case err != nil:
|
||||
back(url.Values{"uperr": {"агент не ответил: " + err.Error()}})
|
||||
case !res.OK:
|
||||
back(url.Values{"uperr": {res.Error}})
|
||||
default:
|
||||
var d struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
_ = json.Unmarshal(res.Data, &d)
|
||||
back(url.Values{"updated": {"1"}, "umsg": {d.Status}})
|
||||
}
|
||||
http.Redirect(w, r, "/agents/"+id+"/edit?updated=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleAgentRename(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -203,7 +421,15 @@ func (s *Server) handleAgentRename(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
|
||||
admins, _ := s.db.ListAdmins()
|
||||
keyCounts, passOff, agentCounts := map[int64]int{}, map[int64]bool{}, map[int64]int{}
|
||||
for _, a := range admins {
|
||||
keyCounts[a.ID] = s.db.CountAdminKeys(a.ID)
|
||||
passOff[a.ID] = s.db.PasswordOff(a.ID)
|
||||
agentCounts[a.ID] = len(s.db.UserAgents(a.ID))
|
||||
}
|
||||
allAgents, _ := s.db.ListAgents()
|
||||
host := s.db.HostAddress()
|
||||
direct := s.db.DirectAddress()
|
||||
secret := s.db.EnrollSecret()
|
||||
code := s.db.InstallCode()
|
||||
ufwInstalled, ufwActive, ufwRunnable := s.ufw.status()
|
||||
@@ -211,8 +437,17 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
|
||||
s.render(w, "settings.html", map[string]any{
|
||||
"Admin": s.currentAdmin(r),
|
||||
"Host": host,
|
||||
"Direct": direct,
|
||||
"DirectShort": fmt.Sprintf("wget -qO- %s/i/%s | sudo sh", direct, code),
|
||||
"DirectCurl": fmt.Sprintf("curl -fsSL %s/install.sh | sudo HOST=%s FALLBACK_HOST=%s ENROLL_SECRET=%s sh", direct, direct, host, secret),
|
||||
"Secret": secret,
|
||||
"CodeLen": s.db.InstallCodeLen(),
|
||||
"Admins": admins,
|
||||
"KeyCounts": keyCounts,
|
||||
"AgentCounts": agentCounts,
|
||||
"AllAgents": allAgents,
|
||||
"Me": s.currentAdmin(r),
|
||||
"PassOff": passOff,
|
||||
"CSRF": csrfToken(r),
|
||||
"InstallShort": fmt.Sprintf("wget -qO- %s/i/%s | sudo sh", host, code),
|
||||
"InstallCurl": fmt.Sprintf("curl -fsSL %s/install.sh | sudo HOST=%s ENROLL_SECRET=%s sh", host, host, secret),
|
||||
@@ -223,18 +458,101 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
|
||||
"UfwRunnable": ufwRunnable,
|
||||
"UfwEnabled": s.db.UfwEnabled(),
|
||||
"UfwRules": ufwRules,
|
||||
"UfwError": s.ufw.LastError(),
|
||||
"TunnelPort": s.TunnelPort(),
|
||||
"TunnelDefault": s.tunnelDefault,
|
||||
"TunnelHost": s.db.TunnelHost(),
|
||||
"TunnelCustom": s.db.TunnelPort() > 0,
|
||||
"Error": map[string]string{
|
||||
"tunnel_host": "Некорректный хост туннеля: нужен IP или имя без схемы и порта.",
|
||||
"host": "Некорректный адрес: нужен URL вида https://panel.example.com, без пути.",
|
||||
"tunnel_port": "Некорректный порт туннеля.",
|
||||
"ufw_port": "Некорректное правило ufw: нужен порт 1-65535 и протокол tcp или udp.",
|
||||
"ufw_off": "Правило ufw не добавлено: ufw выключен или интеграция с ним отключена.",
|
||||
"ufw_failed": "Правило ufw не добавлено: команда ufw завершилась ошибкой.",
|
||||
"ufw_exists": "Этот порт уже открыт в ufw не панелью - правило осталось как было, панель его не отслеживает.",
|
||||
"tunnel_listen": "Не удалось занять новый порт туннеля на хосте (возможно, уже используется). Порт не изменён.",
|
||||
}[r.URL.Query().Get("err")],
|
||||
})
|
||||
}
|
||||
|
||||
// POST /settings/tunnel - host and port agents use for the direct tunnel.
|
||||
func (s *Server) handleSetTunnel(w http.ResponseWriter, r *http.Request) {
|
||||
if !checkCSRF(r) {
|
||||
http.Error(w, "bad csrf", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if s.tunnelDefault == 0 {
|
||||
http.Redirect(w, r, "/settings", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
host := strings.TrimSpace(r.FormValue("tunnel_host"))
|
||||
host = strings.TrimSuffix(strings.TrimPrefix(host, "["), "]")
|
||||
if !validTunnelHost(host) {
|
||||
http.Redirect(w, r, "/settings?err=tunnel_host", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
// An empty port goes back to the one the server was started with.
|
||||
port, custom := s.tunnelDefault, ""
|
||||
if v := strings.TrimSpace(r.FormValue("tunnel_port")); v != "" {
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil || n < 1 || n > 65535 {
|
||||
http.Redirect(w, r, "/settings?err=tunnel_port", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
port = n
|
||||
if n != s.tunnelDefault {
|
||||
custom = v
|
||||
}
|
||||
}
|
||||
if err := s.setTunnelPort(port); err != nil {
|
||||
log.Printf("tunnel: move to port %d: %v", port, err)
|
||||
http.Redirect(w, r, "/settings?err=tunnel_listen", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
_ = s.db.SetConfig(cfgTunnelPort, custom)
|
||||
_ = s.db.SetConfig(cfgTunnelHost, host)
|
||||
http.Redirect(w, r, "/settings", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// validTunnelHost accepts an empty value, an IP, or a bare host name.
|
||||
func validTunnelHost(host string) bool {
|
||||
if host == "" || net.ParseIP(host) != nil {
|
||||
return true
|
||||
}
|
||||
if len(host) > 253 {
|
||||
return false
|
||||
}
|
||||
for _, c := range host {
|
||||
switch {
|
||||
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '-', c == '.', c == '_':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) handleSetHost(w http.ResponseWriter, r *http.Request) {
|
||||
if !checkCSRF(r) {
|
||||
http.Error(w, "bad csrf", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
host := strings.TrimRight(strings.TrimSpace(r.FormValue("host")), "/")
|
||||
direct := strings.TrimRight(strings.TrimSpace(r.FormValue("direct")), "/")
|
||||
for _, v := range []string{host, direct} {
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
if u, err := url.Parse(v); err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || (u.Path != "" && u.Path != "/") {
|
||||
http.Redirect(w, r, "/settings?err=host", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
}
|
||||
if host != "" {
|
||||
_ = s.db.SetConfig(cfgHostAddress, host)
|
||||
}
|
||||
_ = s.db.SetConfig(cfgDirectAddr, direct)
|
||||
http.Redirect(w, r, "/settings", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -245,7 +563,22 @@ func (s *Server) handleRotateSecret(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
_ = s.db.SetConfig(cfgEnrollSecret, randToken())
|
||||
// Rotate the short install code too so old /i/<code> links stop working.
|
||||
_ = s.db.SetConfig(cfgInstallCode, shortCode())
|
||||
_ = s.db.SetConfig(cfgInstallCode, shortCode(s.db.InstallCodeLen()))
|
||||
http.Redirect(w, r, "/settings", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// POST /settings/install/code - a new install code, of the length given:
|
||||
// the old /i/<code> link stops working.
|
||||
func (s *Server) handleInstallCode(w http.ResponseWriter, r *http.Request) {
|
||||
if !checkCSRF(r) {
|
||||
http.Error(w, "bad csrf", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if n, err := strconv.Atoi(r.FormValue("len")); err == nil {
|
||||
n = min(installCodeMax, max(installCodeMin, n))
|
||||
_ = s.db.SetConfig(cfgInstallLen, strconv.Itoa(n))
|
||||
}
|
||||
_ = s.db.SetConfig(cfgInstallCode, shortCode(s.db.InstallCodeLen()))
|
||||
http.Redirect(w, r, "/settings", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -256,12 +589,21 @@ func (s *Server) handleAddAdmin(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
username := strings.TrimSpace(r.FormValue("username"))
|
||||
pw := r.FormValue("password")
|
||||
role := RoleAdmin
|
||||
if r.FormValue("role") == RoleUser {
|
||||
role = RoleUser
|
||||
}
|
||||
if username != "" && len(pw) >= 8 {
|
||||
if hash, err := hashPassword(pw); err == nil {
|
||||
_ = s.db.CreateAdmin(username, hash)
|
||||
if id, err := s.db.CreateUser(username, hash, role); err == nil && role == RoleUser {
|
||||
_ = s.db.SetUserAgents(id, r.Form["agents"])
|
||||
}
|
||||
if me := s.currentAdmin(r); me != nil {
|
||||
s.db.Audit(me.ID, "", "user_add:"+username+":"+role)
|
||||
}
|
||||
}
|
||||
}
|
||||
http.Redirect(w, r, "/settings", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/settings#users", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteAdmin(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -271,7 +613,7 @@ func (s *Server) handleDeleteAdmin(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
id, _ := strconv.ParseInt(r.FormValue("id"), 10, 64)
|
||||
// Never delete the last admin.
|
||||
if n, _ := s.db.CountAdmins(); n > 1 {
|
||||
if u, _ := s.db.AdminByID(id); u != nil && (!u.IsAdmin() || s.db.CountRole(RoleAdmin) > 1) {
|
||||
_ = s.db.DeleteAdmin(id)
|
||||
}
|
||||
http.Redirect(w, r, "/settings", http.StatusSeeOther)
|
||||
@@ -282,10 +624,33 @@ func (s *Server) handleUfwToggle(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "bad csrf", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
_ = s.db.SetUfwEnabled(r.FormValue("enabled") == "1")
|
||||
on := r.FormValue("enabled") == "1"
|
||||
_ = s.db.SetUfwEnabled(on)
|
||||
if on {
|
||||
// Open what forwards created while the integration was off need.
|
||||
s.ufw.sync()
|
||||
}
|
||||
http.Redirect(w, r, "/settings", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleUfwAdd(w http.ResponseWriter, r *http.Request) {
|
||||
if !checkCSRF(r) {
|
||||
http.Error(w, "bad csrf", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
port, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("port")))
|
||||
proto := r.FormValue("proto")
|
||||
if port < 1 || port > 65535 || (proto != "tcp" && proto != "udp") {
|
||||
http.Redirect(w, r, "/settings?err=ufw_port", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
target := "/settings"
|
||||
if key := s.ufw.allowManual(port, proto); key != "" {
|
||||
target += "?err=" + key
|
||||
}
|
||||
http.Redirect(w, r, target, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleUfwRevoke(w http.ResponseWriter, r *http.Request) {
|
||||
if !checkCSRF(r) {
|
||||
http.Error(w, "bad csrf", http.StatusForbidden)
|
||||
@@ -301,7 +666,7 @@ func (s *Server) handleUfwRevoke(w http.ResponseWriter, r *http.Request) {
|
||||
// handleInstallScript serves the generic installer; the secret is NOT baked in,
|
||||
// so it must be supplied via env or the interactive prompt.
|
||||
func (s *Server) handleInstallScript(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderInstall(w, "")
|
||||
s.renderInstall(w, r, "")
|
||||
}
|
||||
|
||||
// handleShortInstall serves the installer behind the short /i/<code> link with
|
||||
@@ -309,21 +674,49 @@ func (s *Server) handleInstallScript(w http.ResponseWriter, r *http.Request) {
|
||||
//
|
||||
// wget -qO- https://host/i/<code> | sudo sh
|
||||
func (s *Server) handleShortInstall(w http.ResponseWriter, r *http.Request) {
|
||||
// A short code can be guessed: wrong ones count against the address
|
||||
// like wrong passwords do.
|
||||
ip := clientIP(r)
|
||||
if s.limiter.blocked(ip) {
|
||||
http.Error(w, "too many attempts", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
code := r.PathValue("code")
|
||||
want := s.db.InstallCode()
|
||||
if want == "" || !constantTimeEqual(code, want) {
|
||||
s.limiter.fin(ip, false)
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
s.renderInstall(w, s.db.EnrollSecret())
|
||||
s.renderInstall(w, r, s.db.EnrollSecret())
|
||||
}
|
||||
|
||||
// renderInstall writes install.sh with the current host and an optional secret.
|
||||
func (s *Server) renderInstall(w http.ResponseWriter, secret string) {
|
||||
// viaDirect reports whether the request came to the direct address.
|
||||
func (s *Server) viaDirect(r *http.Request) bool {
|
||||
d := directHost(s.db.DirectAddress())
|
||||
if d == "" {
|
||||
return false
|
||||
}
|
||||
host := r.Host
|
||||
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||
host = h
|
||||
}
|
||||
return strings.EqualFold(host, d)
|
||||
}
|
||||
|
||||
// renderInstall writes install.sh with the server's addresses and an
|
||||
// optional secret. Fetched from the direct address, it makes that the
|
||||
// agent's address and the main one its fallback; otherwise the other way.
|
||||
func (s *Server) renderInstall(w http.ResponseWriter, r *http.Request, secret string) {
|
||||
host, fallback := s.db.HostAddress(), s.db.DirectAddress()
|
||||
if s.viaDirect(r) {
|
||||
host, fallback = fallback, host
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/x-shellscript; charset=utf-8")
|
||||
if err := s.installTmpl.Execute(w, map[string]any{
|
||||
"Host": s.db.HostAddress(),
|
||||
"Secret": secret,
|
||||
"Host": host,
|
||||
"Fallback": fallback,
|
||||
"Secret": secret,
|
||||
}); err != nil {
|
||||
log.Printf("install.sh render: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
@@ -335,3 +728,11 @@ func (s *Server) downloadHandler() http.Handler {
|
||||
// install one-liner works before the machine is enrolled.
|
||||
return http.FileServer(http.Dir(s.distDir))
|
||||
}
|
||||
|
||||
// viaDirectOf tells whether the agent last reached the server at the direct
|
||||
// address.
|
||||
func viaDirectOf(m *sync.Map, id string) bool {
|
||||
v, _ := m.Load(id)
|
||||
b, _ := v.(bool)
|
||||
return b
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"path"
|
||||
"time"
|
||||
|
||||
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||
@@ -13,15 +11,24 @@ import (
|
||||
|
||||
// fsOps maps the URL op segment to a proto task type.
|
||||
var fsOps = map[string]string{
|
||||
"list": proto.TaskFSList,
|
||||
"read": proto.TaskFSRead,
|
||||
"write": proto.TaskFSWrite,
|
||||
"mkdir": proto.TaskFSMkdir,
|
||||
"delete": proto.TaskFSDelete,
|
||||
"deltree": proto.TaskFSDelTree,
|
||||
"unzip": proto.TaskFSUnzip,
|
||||
"zip": proto.TaskFSZip,
|
||||
"move": proto.TaskFSMove,
|
||||
"list": proto.TaskFSList,
|
||||
"read": proto.TaskFSRead,
|
||||
"write": proto.TaskFSWrite,
|
||||
"mkdir": proto.TaskFSMkdir,
|
||||
"delete": proto.TaskFSDelete,
|
||||
"deltree": proto.TaskFSDelTree,
|
||||
"unzip": proto.TaskFSUnzip,
|
||||
"zip": proto.TaskFSZip,
|
||||
"move": proto.TaskFSMove,
|
||||
"stat": proto.TaskFSStat,
|
||||
"chmod": proto.TaskFSChmod,
|
||||
"copy": proto.TaskFSCopy,
|
||||
"db.tables": proto.TaskDBTables,
|
||||
"db.rows": proto.TaskDBRows,
|
||||
"db.set": proto.TaskDBSet,
|
||||
"db.add": proto.TaskDBAdd,
|
||||
"db.del": proto.TaskDBDel,
|
||||
"db.sql": proto.TaskDBSQL,
|
||||
}
|
||||
|
||||
// fsTaskTimeout bounds how long we wait for an agent to answer a file op.
|
||||
@@ -34,10 +41,10 @@ func (s *Server) handleFilesPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
s.render(w, "files.html", map[string]any{"Agent": a})
|
||||
s.render(w, "files.html", map[string]any{"Agent": a, "EditLimit": proto.FSReadCap})
|
||||
}
|
||||
|
||||
// POST /api/panel/agents/{id}/fs/{op} — run a file-manager operation on the
|
||||
// POST /api/panel/agents/{id}/fs/{op} - run a file-manager operation on the
|
||||
// agent and return its result. The request body is the op's JSON args and is
|
||||
// forwarded verbatim to the agent.
|
||||
func (s *Server) handleFSOp(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -53,7 +60,7 @@ func (s *Server) handleFSOp(w http.ResponseWriter, r *http.Request) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if !a.Online() {
|
||||
if !s.online(a) {
|
||||
writeJSON(w, proto.TaskResult{Error: "машина офлайн"})
|
||||
return
|
||||
}
|
||||
@@ -63,6 +70,26 @@ func (s *Server) handleFSOp(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Changes to a database go to the audit log, with what was done.
|
||||
if op == "db.set" || op == "db.add" || op == "db.del" || op == "db.sql" {
|
||||
var d struct {
|
||||
Path, Table, Column, SQL string
|
||||
}
|
||||
_ = json.Unmarshal(args, &d)
|
||||
what := d.Path + " " + d.Table
|
||||
if d.Column != "" {
|
||||
what += "." + d.Column
|
||||
}
|
||||
if d.SQL != "" {
|
||||
what += " " + d.SQL
|
||||
}
|
||||
if len(what) > 300 {
|
||||
what = what[:300]
|
||||
}
|
||||
if admin := s.currentAdmin(r); admin != nil {
|
||||
s.db.Audit(admin.ID, agentID, op+":"+what)
|
||||
}
|
||||
}
|
||||
taskID, resultCh := s.hub.CreateTask(agentID, taskType, args)
|
||||
defer s.hub.DiscardTask(taskID)
|
||||
|
||||
@@ -73,44 +100,3 @@ func (s *Server) handleFSOp(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, proto.TaskResult{Error: "таймаут ожидания агента"})
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/panel/agents/{id}/fs/download?path=... — stream a file as a download.
|
||||
func (s *Server) handleFSDownload(w http.ResponseWriter, r *http.Request) {
|
||||
agentID := r.PathValue("id")
|
||||
p := r.URL.Query().Get("path")
|
||||
a, err := s.db.GetAgent(agentID)
|
||||
if err != nil || a == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if !a.Online() {
|
||||
http.Error(w, "машина офлайн", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
args, _ := json.Marshal(proto.FSPathArgs{Path: p})
|
||||
taskID, ch := s.hub.CreateTask(agentID, proto.TaskFSDownload, args)
|
||||
defer s.hub.DiscardTask(taskID)
|
||||
|
||||
select {
|
||||
case res := <-ch:
|
||||
if !res.OK {
|
||||
http.Error(w, res.Error, http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
var rr proto.FSReadResult
|
||||
if err := json.Unmarshal(res.Data, &rr); err != nil {
|
||||
http.Error(w, "bad result", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
data, _ := base64.StdEncoding.DecodeString(rr.Content)
|
||||
name := path.Base(p)
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=\""+name+"\"")
|
||||
if rr.Truncated {
|
||||
w.Header().Set("X-Truncated", "1")
|
||||
}
|
||||
_, _ = w.Write(data)
|
||||
case <-time.After(fsTaskTimeout):
|
||||
http.Error(w, "таймаут ожидания агента", http.StatusGatewayTimeout)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package server
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
@@ -17,18 +18,37 @@ func (s *Server) handleForwardsPage(w http.ResponseWriter, r *http.Request) {
|
||||
rules, _ := s.db.ListForwardRulesByAgent(id)
|
||||
errMsg := map[string]string{
|
||||
"busy": "Этот порт хоста уже занят другим правилом.",
|
||||
"tunnel": "Этот порт занят прямым туннелем панели (см. Настройки).",
|
||||
"port": "Некорректный номер порта.",
|
||||
"listen": "Не удалось занять порт на хосте (возможно, уже используется).",
|
||||
"db": "Ошибка базы данных.",
|
||||
}[r.URL.Query().Get("err")]
|
||||
s.render(w, "forwards.html", map[string]any{
|
||||
"Agent": a,
|
||||
"Rules": rules,
|
||||
"CSRF": csrfToken(r),
|
||||
"Error": errMsg,
|
||||
"Agent": a,
|
||||
"Rules": rules,
|
||||
"CSRF": csrfToken(r),
|
||||
"Error": errMsg,
|
||||
"TunnelPort": s.TunnelPort(),
|
||||
"PanelPort": panelPort(s.db.HostAddress()),
|
||||
"UfwEnabled": s.ufw.enabled(),
|
||||
})
|
||||
}
|
||||
|
||||
// panelPort is the port of the panel URL agents dial, for the mode hints.
|
||||
func panelPort(host string) string {
|
||||
u, err := url.Parse(host)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
if p := u.Port(); p != "" {
|
||||
return p
|
||||
}
|
||||
if u.Scheme == "http" {
|
||||
return "80"
|
||||
}
|
||||
return "443"
|
||||
}
|
||||
|
||||
// POST /agents/{id}/forwards/add
|
||||
func (s *Server) handleForwardAdd(w http.ResponseWriter, r *http.Request) {
|
||||
if !checkCSRF(r) {
|
||||
@@ -57,12 +77,17 @@ func (s *Server) handleForwardAdd(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/agents/"+id+"/forwards?err=port", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
// Reject a host port already bound by another forward rule.
|
||||
// Reject a host port already bound by another forward rule or the tunnel.
|
||||
if hostPort == s.TunnelPort() {
|
||||
http.Redirect(w, r, "/agents/"+id+"/forwards?err=tunnel", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if s.db.HostPortUsed(hostPort) {
|
||||
http.Redirect(w, r, "/agents/"+id+"/forwards?err=busy", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
rule := ForwardRule{AgentID: id, ClientPort: clientPort, HostPort: hostPort, ListenAddr: listenAddr, Proto: protoKind}
|
||||
rule := ForwardRule{AgentID: id, ClientPort: clientPort, HostPort: hostPort, ListenAddr: listenAddr, Proto: protoKind,
|
||||
NoUfw: r.FormValue("no_ufw") != "", Label: forwardLabel(r.FormValue("label"))}
|
||||
ruleID, err := s.db.CreateForwardRule(rule)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/agents/"+id+"/forwards?err=db", http.StatusSeeOther)
|
||||
@@ -89,13 +114,35 @@ func (s *Server) handleForwardDelete(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
rid, _ := strconv.ParseInt(r.PathValue("rid"), 10, 64)
|
||||
rule, _ := s.db.GetForwardRule(rid)
|
||||
s.fwd.close(rid)
|
||||
_ = s.db.DeleteForwardRule(rid)
|
||||
// Remove the ufw rule bound to this forward rule (shared 8446 stays).
|
||||
s.ufw.revokeForRule(rid)
|
||||
if rule == nil || !s.db.CanUseAgent(s.currentAdmin(r), rule.AgentID) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
s.removeForward(rid)
|
||||
dest := "/"
|
||||
if rule != nil {
|
||||
dest = "/agents/" + rule.AgentID + "/forwards"
|
||||
}
|
||||
http.Redirect(w, r, dest, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// removeForward closes a forward rule's listener and deletes the rule, along
|
||||
// with the ufw rule bound to it (the shared tunnel port stays while another
|
||||
// direct forward needs it).
|
||||
func (s *Server) removeForward(rid int64) {
|
||||
s.fwd.close(rid)
|
||||
_ = s.db.DeleteForwardRule(rid)
|
||||
s.ufw.revokeForRule(rid)
|
||||
}
|
||||
|
||||
// forwardLabelMax bounds a rule's label, in characters.
|
||||
const forwardLabelMax = 80
|
||||
|
||||
// forwardLabel tidies the optional note of a rule: one line, bounded length.
|
||||
func forwardLabel(v string) string {
|
||||
v = strings.Join(strings.Fields(v), " ")
|
||||
if r := []rune(v); len(r) > forwardLabelMax {
|
||||
v = string(r[:forwardLabelMax])
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,542 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||
)
|
||||
|
||||
const (
|
||||
// statTaskTimeout bounds how long a status request waits for the agent.
|
||||
statTaskTimeout = 8 * time.Second
|
||||
// statCacheFor is how long one sample answers every open status page, so
|
||||
// several tabs cost the agent a single reading.
|
||||
statCacheFor = 2 * time.Second
|
||||
// speedWindow is how long the agent is asked to measure each direction
|
||||
// for. An agent that predates the argument keeps to its own 3 s.
|
||||
speedWindow = 7 * time.Second
|
||||
// speedTaskTimeout is how long a speed test may take. One that is not done
|
||||
// by then is answered with what the server has counted itself.
|
||||
speedTaskTimeout = 35 * time.Second
|
||||
// speedDownCap bounds one download stream; speedDownFor ends it if the
|
||||
// agent does not hang up first.
|
||||
speedDownCap = 1 << 30
|
||||
speedDownFor = 2 * speedWindow
|
||||
speedUpCap = 64 << 20
|
||||
// speedRelayUpFor is how long the agent's upload is passed on to the
|
||||
// browser: the agent's own window with slack.
|
||||
speedRelayUpFor = speedWindow + time.Second
|
||||
)
|
||||
|
||||
// statCache holds the last sample per agent and serializes its refresh.
|
||||
type statCache struct {
|
||||
mu sync.Mutex
|
||||
m map[string]*statEntry
|
||||
}
|
||||
|
||||
type statEntry struct {
|
||||
mu sync.Mutex
|
||||
at time.Time
|
||||
res proto.TaskResult
|
||||
}
|
||||
|
||||
func (c *statCache) entry(agentID string) *statEntry {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.m == nil {
|
||||
c.m = map[string]*statEntry{}
|
||||
}
|
||||
e := c.m[agentID]
|
||||
if e == nil {
|
||||
e = &statEntry{}
|
||||
c.m[agentID] = e
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
func (c *statCache) drop(agentID string) {
|
||||
c.mu.Lock()
|
||||
delete(c.m, agentID)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Server) handleStatusPage(w http.ResponseWriter, r *http.Request) {
|
||||
a, err := s.db.GetAgent(r.PathValue("id"))
|
||||
if err != nil || a == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
s.render(w, "status.html", map[string]any{"Agent": a, "RelayUpMs": speedRelayUpFor.Milliseconds()})
|
||||
}
|
||||
|
||||
// runTask runs a one-shot task on the agent and waits for its result.
|
||||
func (s *Server) runTask(agentID, typ string, args []byte, timeout time.Duration) (proto.TaskResult, error) {
|
||||
taskID, ch := s.hub.CreateTask(agentID, typ, args)
|
||||
defer s.hub.DiscardTask(taskID)
|
||||
t := time.NewTimer(timeout)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case res := <-ch:
|
||||
return res, nil
|
||||
case <-t.C:
|
||||
return proto.TaskResult{}, errors.New("таймаут ожидания клиента")
|
||||
}
|
||||
}
|
||||
|
||||
// onlineAgent answers the request itself and returns false when the agent is
|
||||
// unknown or offline.
|
||||
func (s *Server) onlineAgent(w http.ResponseWriter, r *http.Request) (string, bool) {
|
||||
id := r.PathValue("id")
|
||||
a, err := s.db.GetAgent(id)
|
||||
if err != nil || a == nil {
|
||||
http.NotFound(w, r)
|
||||
return "", false
|
||||
}
|
||||
if !s.online(a) {
|
||||
writeJSON(w, proto.TaskResult{Error: "машина офлайн"})
|
||||
return "", false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
// oldAgentError replaces the "unknown task" answer of an agent that predates
|
||||
// the task.
|
||||
func oldAgentError(res proto.TaskResult) proto.TaskResult {
|
||||
if !res.OK && strings.Contains(res.Error, "неизвестная операция") {
|
||||
res.Error = "клиент не поддерживает эту функцию - обновите его (нужен 0.0.5+)"
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// GET /api/panel/agents/{id}/stat - CPU, RAM and disk of the machine right now.
|
||||
func (s *Server) handleAgentStat(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := s.onlineAgent(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
e := s.stats.entry(id)
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
if time.Since(e.at) > statCacheFor {
|
||||
res, err := s.runTask(id, proto.TaskSysStat, nil, statTaskTimeout)
|
||||
if err != nil {
|
||||
// Cached too, so pages queued behind this one don't each wait it out.
|
||||
res = proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
e.res, e.at = oldAgentError(res), time.Now()
|
||||
}
|
||||
writeJSON(w, e.res)
|
||||
}
|
||||
|
||||
// POST /api/panel/agents/{id}/speed - have the agent measure its link to us.
|
||||
// With relay=1 its streams run through the relay opened for it, to and from
|
||||
// the browser.
|
||||
func (s *Server) handleAgentSpeed(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := s.onlineAgent(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
relay := r.URL.Query().Get("relay") != ""
|
||||
m := s.meters.start(id)
|
||||
defer s.meters.stop(id, m)
|
||||
if relay {
|
||||
rl := s.relays.get(id)
|
||||
if rl == nil {
|
||||
writeJSON(w, proto.TaskResult{Error: "замер через браузер не открыт"})
|
||||
return
|
||||
}
|
||||
defer s.relays.drop(id, rl)
|
||||
}
|
||||
args, _ := json.Marshal(proto.SysSpeedArgs{Relay: relay, WindowMs: int(speedWindow.Milliseconds())})
|
||||
res, err := s.runTask(id, proto.TaskSysSpeed, args, speedTaskTimeout)
|
||||
if err != nil {
|
||||
part, ok := m.result()
|
||||
if !ok {
|
||||
writeJSON(w, proto.TaskResult{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
part.Relay = relay
|
||||
data, _ := json.Marshal(part)
|
||||
res = proto.TaskResult{OK: true, Data: data}
|
||||
}
|
||||
res = oldAgentError(res)
|
||||
if relay && res.OK {
|
||||
// An agent that predates the relay ignores the argument and measures
|
||||
// its own link.
|
||||
var d proto.SysSpeedResult
|
||||
if json.Unmarshal(res.Data, &d) != nil || !d.Relay {
|
||||
res = proto.TaskResult{Error: "клиент не умеет замер через браузер - обновите его (нужен 0.0.6+)"}
|
||||
}
|
||||
}
|
||||
writeJSON(w, res)
|
||||
}
|
||||
|
||||
// speedMeter counts what flows through the streams of one speed test as the
|
||||
// server sees it: for the live reading on the page, and for the result of a
|
||||
// test that ran out of time.
|
||||
type speedMeter struct {
|
||||
mu sync.Mutex
|
||||
start time.Time
|
||||
phase string // "ping", "down", "up"
|
||||
pingAt time.Time
|
||||
pingMs float64
|
||||
down speedCount
|
||||
up speedCount
|
||||
}
|
||||
|
||||
// speedCount is the bytes of one direction and when they went through. The
|
||||
// first piece only starts the clock.
|
||||
type speedCount struct {
|
||||
n, skip int64
|
||||
first, last time.Time
|
||||
}
|
||||
|
||||
func (c *speedCount) add(n int) {
|
||||
now := time.Now()
|
||||
if c.first.IsZero() {
|
||||
c.first, c.skip = now, int64(n)
|
||||
}
|
||||
c.n += int64(n)
|
||||
c.last = now
|
||||
}
|
||||
|
||||
func (c *speedCount) bps() float64 {
|
||||
d := c.last.Sub(c.first).Seconds()
|
||||
if d <= 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(c.n-c.skip) * 8 / d
|
||||
}
|
||||
|
||||
// ping notes one of the agent's ping probes: they come one right after
|
||||
// another, so the gap between two is its round trip.
|
||||
func (m *speedMeter) ping() {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
now := time.Now()
|
||||
if !m.pingAt.IsZero() {
|
||||
if ms := float64(now.Sub(m.pingAt).Microseconds()) / 1000; m.pingMs == 0 || ms < m.pingMs {
|
||||
m.pingMs = ms
|
||||
}
|
||||
}
|
||||
m.pingAt = now
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *speedMeter) add(up bool, n int) {
|
||||
if m == nil || n <= 0 {
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
if up {
|
||||
m.phase = "up"
|
||||
m.up.add(n)
|
||||
} else {
|
||||
m.phase = "down"
|
||||
m.down.add(n)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// result is the test as counted so far; false with nothing measured yet.
|
||||
func (m *speedMeter) result() (proto.SysSpeedResult, bool) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
res := proto.SysSpeedResult{PingMs: m.pingMs, DownBps: m.down.bps(), UpBps: m.up.bps(), Partial: true}
|
||||
return res, res.DownBps > 0
|
||||
}
|
||||
|
||||
// meterWriter counts what it passes on; a nil meter counts nothing.
|
||||
type meterWriter struct {
|
||||
w io.Writer
|
||||
m *speedMeter
|
||||
up bool
|
||||
}
|
||||
|
||||
func (c meterWriter) Write(p []byte) (int, error) {
|
||||
n, err := c.w.Write(p)
|
||||
c.m.add(c.up, n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// speedMeters holds the meter of each agent under test.
|
||||
type speedMeters struct {
|
||||
mu sync.Mutex
|
||||
m map[string]*speedMeter
|
||||
}
|
||||
|
||||
func (c *speedMeters) start(agentID string) *speedMeter {
|
||||
m := &speedMeter{start: time.Now(), phase: "ping"}
|
||||
c.mu.Lock()
|
||||
if c.m == nil {
|
||||
c.m = map[string]*speedMeter{}
|
||||
}
|
||||
c.m[agentID] = m
|
||||
c.mu.Unlock()
|
||||
return m
|
||||
}
|
||||
|
||||
func (c *speedMeters) get(agentID string) *speedMeter {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.m[agentID]
|
||||
}
|
||||
|
||||
// stop forgets the agent's meter if it is still m.
|
||||
func (c *speedMeters) stop(agentID string, m *speedMeter) {
|
||||
c.mu.Lock()
|
||||
if c.m[agentID] == m {
|
||||
delete(c.m, agentID)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// GET /api/panel/agents/{id}/speed/progress - the bytes counted so far in the
|
||||
// agent's running speed test, for the page to turn into a live rate. An empty
|
||||
// phase means no test is running.
|
||||
func (s *Server) handleSpeedProgress(w http.ResponseWriter, r *http.Request) {
|
||||
var out struct {
|
||||
Phase string `json:"phase"`
|
||||
Down int64 `json:"down"`
|
||||
Up int64 `json:"up"`
|
||||
Ms int64 `json:"ms"` // since the test began, by the server's clock
|
||||
}
|
||||
if m := s.meters.get(r.PathValue("id")); m != nil {
|
||||
m.mu.Lock()
|
||||
out.Phase, out.Down, out.Up = m.phase, m.down.n, m.up.n
|
||||
out.Ms = time.Since(m.start).Milliseconds()
|
||||
m.mu.Unlock()
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
writeJSON(w, out)
|
||||
}
|
||||
|
||||
// speedBlock is one chunk of incompressible bytes, made once and sent over and
|
||||
// over, so a speed test costs the server no CPU beyond the copy.
|
||||
var speedBlock = sync.OnceValue(func() []byte {
|
||||
b := make([]byte, 64<<10)
|
||||
rand.Read(b)
|
||||
return b
|
||||
})
|
||||
|
||||
func speedStreamHeaders(w http.ResponseWriter) {
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
// Keep a proxy in front from buffering or compressing the stream.
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
}
|
||||
|
||||
// GET /api/agent/speed/down?bytes=N - stream N bytes (default: as much as fits
|
||||
// in the time window) for the agent to time. bytes=0 is the ping probe.
|
||||
func (s *Server) handleSpeedDown(w http.ResponseWriter, r *http.Request) {
|
||||
a := s.authenticateAgent(r)
|
||||
if a == nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
m := s.meters.get(a.ID)
|
||||
left := speedDownCap
|
||||
if v := r.URL.Query().Get("bytes"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n >= 0 && n < left {
|
||||
left = n
|
||||
}
|
||||
}
|
||||
if left == 0 {
|
||||
m.ping()
|
||||
}
|
||||
speedStreamHeaders(w)
|
||||
block := speedBlock()
|
||||
deadline := time.Now().Add(speedDownFor)
|
||||
for left > 0 && time.Now().Before(deadline) && r.Context().Err() == nil {
|
||||
chunk := block
|
||||
if len(chunk) > left {
|
||||
chunk = chunk[:left]
|
||||
}
|
||||
n, err := w.Write(chunk)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
m.add(false, n)
|
||||
left -= n
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/agent/speed/up - swallow the agent's upload.
|
||||
func (s *Server) handleSpeedUp(w http.ResponseWriter, r *http.Request) {
|
||||
a := s.authenticateAgent(r)
|
||||
if a == nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
sink := meterWriter{w: io.Discard, m: s.meters.get(a.ID), up: true}
|
||||
_, _ = io.Copy(sink, http.MaxBytesReader(w, r.Body, speedUpCap))
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// speedRelay joins the agent's speed test streams to the browser's, so the
|
||||
// bytes travel browser - panel - client. Both pipes are unbuffered: a slow
|
||||
// reader holds its writer back, and the rate is that of the whole path.
|
||||
type speedRelay struct {
|
||||
downR *io.PipeReader // browser -> agent
|
||||
downW *io.PipeWriter
|
||||
upR *io.PipeReader // agent -> browser
|
||||
upW *io.PipeWriter
|
||||
}
|
||||
|
||||
func (rl *speedRelay) close() {
|
||||
rl.downR.CloseWithError(io.ErrClosedPipe)
|
||||
rl.downW.CloseWithError(io.ErrClosedPipe)
|
||||
rl.upR.CloseWithError(io.ErrClosedPipe)
|
||||
rl.upW.CloseWithError(io.ErrClosedPipe)
|
||||
}
|
||||
|
||||
// speedRelays holds the relay of each agent under test, one per agent.
|
||||
type speedRelays struct {
|
||||
mu sync.Mutex
|
||||
m map[string]*speedRelay
|
||||
}
|
||||
|
||||
// open starts a fresh relay for the agent, ending the one it had.
|
||||
func (c *speedRelays) open(agentID string) {
|
||||
rl := &speedRelay{}
|
||||
rl.downR, rl.downW = io.Pipe()
|
||||
rl.upR, rl.upW = io.Pipe()
|
||||
c.mu.Lock()
|
||||
if c.m == nil {
|
||||
c.m = map[string]*speedRelay{}
|
||||
}
|
||||
old := c.m[agentID]
|
||||
c.m[agentID] = rl
|
||||
c.mu.Unlock()
|
||||
if old != nil {
|
||||
old.close()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *speedRelays) get(agentID string) *speedRelay {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.m[agentID]
|
||||
}
|
||||
|
||||
// drop ends the agent's relay; with rl given, only if it is still that one.
|
||||
func (c *speedRelays) drop(agentID string, rl *speedRelay) {
|
||||
c.mu.Lock()
|
||||
cur := c.m[agentID]
|
||||
if cur != nil && (rl == nil || rl == cur) {
|
||||
delete(c.m, agentID)
|
||||
} else {
|
||||
cur = nil
|
||||
}
|
||||
c.mu.Unlock()
|
||||
if cur != nil {
|
||||
cur.close()
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/panel/ping - an empty answer for the browser to time.
|
||||
func (s *Server) handlePanelPing(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// POST /api/panel/agents/{id}/speed/relay - open the relay of a speed test
|
||||
// that runs through the browser. The test itself ends it.
|
||||
func (s *Server) handleSpeedRelayOpen(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := s.onlineAgent(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
s.relays.open(id)
|
||||
writeJSON(w, proto.TaskResult{OK: true})
|
||||
}
|
||||
|
||||
// POST /api/panel/agents/{id}/speed/down - a piece of the browser's upload,
|
||||
// passed on to the agent. 204 asks for the next piece, 200 says the agent has
|
||||
// stopped reading.
|
||||
func (s *Server) handleSpeedRelayFeed(w http.ResponseWriter, r *http.Request) {
|
||||
body := http.MaxBytesReader(w, r.Body, speedUpCap)
|
||||
rl := s.relays.get(r.PathValue("id"))
|
||||
if rl != nil {
|
||||
if _, err := io.Copy(rl.downW, body); err == nil {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
}
|
||||
// Take the rest of the piece before answering: an answer sent over an
|
||||
// unread body makes Go drop the connection, which a proxy in front turns
|
||||
// into a 502.
|
||||
_, _ = io.Copy(io.Discard, body)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = io.WriteString(w, "done")
|
||||
}
|
||||
|
||||
// GET /api/panel/agents/{id}/speed/up - the agent's upload, for the browser to
|
||||
// time. Ends when the agent is done sending.
|
||||
func (s *Server) handleSpeedRelayDrain(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
rl := s.relays.get(id)
|
||||
if rl == nil {
|
||||
http.Error(w, "gone", http.StatusGone)
|
||||
return
|
||||
}
|
||||
speedStreamHeaders(w)
|
||||
_, _ = io.Copy(meterWriter{w: w, m: s.meters.get(id), up: true}, rl.upR)
|
||||
// The browser may have left: don't let the agent keep sending to nobody.
|
||||
rl.upR.CloseWithError(io.ErrClosedPipe)
|
||||
}
|
||||
|
||||
// GET /api/agent/speed/relay/down - what the browser uploads, for the agent to
|
||||
// time.
|
||||
func (s *Server) handleSpeedRelayDown(w http.ResponseWriter, r *http.Request) {
|
||||
a := s.authenticateAgent(r)
|
||||
if a == nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
rl := s.relays.get(a.ID)
|
||||
if rl == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
speedStreamHeaders(w)
|
||||
// The agent waits on the headers, the browser may not have sent a byte yet.
|
||||
_ = http.NewResponseController(w).Flush()
|
||||
_, _ = io.Copy(meterWriter{w: w, m: s.meters.get(a.ID)}, rl.downR)
|
||||
// The agent hung up at the end of its window: stop the browser's upload.
|
||||
rl.downR.CloseWithError(io.ErrClosedPipe)
|
||||
}
|
||||
|
||||
// POST /api/agent/speed/relay/up - the agent's upload, passed on to the browser.
|
||||
func (s *Server) handleSpeedRelayUp(w http.ResponseWriter, r *http.Request) {
|
||||
a := s.authenticateAgent(r)
|
||||
if a == nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
rl := s.relays.get(a.ID)
|
||||
if rl == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
body := http.MaxBytesReader(w, r.Body, speedUpCap)
|
||||
// A proxy in front may hand over the whole upload at once: pass on only
|
||||
// what the browser takes in the window, or the agent would wait for the
|
||||
// rest to trickle through a slower link.
|
||||
t := time.AfterFunc(speedRelayUpFor, func() { rl.upW.Close() })
|
||||
_, _ = io.Copy(rl.upW, body)
|
||||
t.Stop()
|
||||
rl.upW.Close()
|
||||
_, _ = io.Copy(io.Discard, body)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -1,9 +1,18 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/coder/websocket/wsjson"
|
||||
|
||||
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||
)
|
||||
@@ -12,7 +21,9 @@ import (
|
||||
// same transport the agent uses), so it works through any reverse proxy without
|
||||
// WebSocket upgrade support.
|
||||
|
||||
// POST /api/panel/term/{agentID}/open — create a terminal session.
|
||||
// POST /api/panel/term/{agentID}/open - create a terminal session: a shell
|
||||
// on the agent, or with {"ssh": {...}} in the body a shell the agent opens
|
||||
// by SSH on another host.
|
||||
func (s *Server) handlePanelOpen(w http.ResponseWriter, r *http.Request) {
|
||||
agentID := r.PathValue("agentID")
|
||||
agent, err := s.db.GetAgent(agentID)
|
||||
@@ -21,14 +32,41 @@ func (s *Server) handlePanelOpen(w http.ResponseWriter, r *http.Request) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
sess := s.hub.CreateSession(agentID)
|
||||
var body struct {
|
||||
SSH *proto.SSHTarget `json:"ssh"`
|
||||
}
|
||||
if r.ContentLength != 0 {
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&body); err != nil && err != io.EOF {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
if t := body.SSH; t != nil {
|
||||
t.Host, t.User = strings.TrimSpace(t.Host), strings.TrimSpace(t.User)
|
||||
if t.Port == 0 {
|
||||
t.Port = 22
|
||||
}
|
||||
if t.Host == "" || t.User == "" || t.Port < 1 || t.Port > 65535 || strings.ContainsAny(t.Host+t.User, " \t\r\n/") {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": "нужны хост, порт 1-65535 и пользователь"})
|
||||
return
|
||||
}
|
||||
sess := s.hub.CreateSSH(agentID, *t)
|
||||
if admin := s.currentAdmin(r); admin != nil {
|
||||
s.db.Audit(admin.ID, agentID, "open_ssh:"+t.User+"@"+net.JoinHostPort(t.Host, strconv.Itoa(t.Port)))
|
||||
}
|
||||
writeJSON(w, map[string]string{"session_id": sess.ID})
|
||||
return
|
||||
}
|
||||
sess := s.hub.CreateSession(agentID, agent.LoginUser)
|
||||
if admin := s.currentAdmin(r); admin != nil {
|
||||
s.db.Audit(admin.ID, agentID, "open_terminal")
|
||||
s.db.Audit(admin.ID, agentID, "open_terminal:"+agent.TerminalUser())
|
||||
}
|
||||
writeJSON(w, map[string]string{"session_id": sess.ID})
|
||||
}
|
||||
|
||||
// GET /api/panel/term/{sid}/out — long-poll for PTY output.
|
||||
// GET /api/panel/term/{sid}/out - long-poll for PTY output.
|
||||
func (s *Server) handlePanelOut(w http.ResponseWriter, r *http.Request) {
|
||||
sess := s.hub.Session(r.PathValue("sid"))
|
||||
if sess == nil {
|
||||
@@ -49,9 +87,12 @@ type panelInMsg struct {
|
||||
D string `json:"d,omitempty"`
|
||||
Cols uint16 `json:"cols,omitempty"`
|
||||
Rows uint16 `json:"rows,omitempty"`
|
||||
// Seq numbers the messages of a session from 1; 0 from a page that
|
||||
// predates it.
|
||||
Seq uint64 `json:"seq,omitempty"`
|
||||
}
|
||||
|
||||
// POST /api/panel/term/{sid}/in — send keystrokes/resize to the agent PTY.
|
||||
// POST /api/panel/term/{sid}/in - send keystrokes/resize to the agent PTY.
|
||||
func (s *Server) handlePanelIn(w http.ResponseWriter, r *http.Request) {
|
||||
sess := s.hub.Session(r.PathValue("sid"))
|
||||
if sess == nil {
|
||||
@@ -63,16 +104,89 @@ func (s *Server) handlePanelIn(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
takePanelIn(sess, m)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// takePanelIn passes the browser's keystrokes or resize on to the agent; a
|
||||
// repeat of one already taken is dropped.
|
||||
func takePanelIn(sess *Session, m panelInMsg) {
|
||||
if m.Seq > 0 && !sess.TakeSeq(m.Seq) {
|
||||
return
|
||||
}
|
||||
switch m.T {
|
||||
case "i":
|
||||
sess.PushIn(proto.InFrame{Type: proto.FrameStdin, Data: m.D})
|
||||
case "r":
|
||||
sess.PushIn(proto.InFrame{Type: proto.FrameResize, Cols: m.Cols, Rows: m.Rows})
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// POST /api/panel/term/{sid}/close — tear the session down.
|
||||
// GET /api/panel/term/{sid}/ws - the browser's terminal on a WebSocket: what
|
||||
// the "in" requests send comes as messages, and output goes out as soon as
|
||||
// it is there, in the "out" answers' shape. The page falls back to those
|
||||
// requests when this does not connect.
|
||||
func (s *Server) handlePanelTermWS(w http.ResponseWriter, r *http.Request) {
|
||||
sess := s.hub.Session(r.PathValue("sid"))
|
||||
if sess == nil {
|
||||
http.Error(w, "closed", http.StatusGone)
|
||||
return
|
||||
}
|
||||
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{CompressionMode: websocket.CompressionDisabled})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
conn.SetReadLimit(1 << 20)
|
||||
defer conn.CloseNow()
|
||||
ctx, cancel := context.WithCancel(r.Context())
|
||||
defer cancel()
|
||||
go func() {
|
||||
defer cancel()
|
||||
for ctx.Err() == nil {
|
||||
msgs, alive := sess.PollOutUntil(ctx.Done(), pollHold)
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
out := map[string]any{"frames": msgs}
|
||||
if !alive {
|
||||
out = map[string]any{"closed": true}
|
||||
}
|
||||
wctx, wcancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
err := wsjson.Write(wctx, conn, out)
|
||||
wcancel()
|
||||
if err != nil || !alive {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
t := time.NewTicker(wsPing)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
pctx, pcancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
err := conn.Ping(pctx)
|
||||
pcancel()
|
||||
if err != nil {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
for {
|
||||
var m panelInMsg
|
||||
if err := wsjson.Read(ctx, conn, &m); err != nil {
|
||||
return
|
||||
}
|
||||
takePanelIn(sess, m)
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/panel/term/{sid}/close - tear the session down.
|
||||
func (s *Server) handlePanelClose(w http.ResponseWriter, r *http.Request) {
|
||||
s.hub.CloseSession(r.PathValue("sid"))
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
||||
+116
-8
@@ -22,6 +22,12 @@ type Hub struct {
|
||||
|
||||
sessions map[string]*Session
|
||||
tasks map[string]*taskEntry
|
||||
|
||||
// Presence: control polls held open right now per agent, and when each
|
||||
// agent's last one ended. started is when this hub came up.
|
||||
polls map[string]int
|
||||
lastPoll map[string]time.Time
|
||||
started time.Time
|
||||
}
|
||||
|
||||
// taskEntry is a pending one-shot task awaiting the agent's result.
|
||||
@@ -31,7 +37,7 @@ type taskEntry struct {
|
||||
}
|
||||
|
||||
// Session is one live byte stream between the panel and an agent (a PTY terminal
|
||||
// or a raw TCP forward — the hub treats both identically).
|
||||
// or a raw TCP forward - the hub treats both identically).
|
||||
type Session struct {
|
||||
ID string
|
||||
AgentID string
|
||||
@@ -39,6 +45,7 @@ type Session struct {
|
||||
mu sync.Mutex
|
||||
inBuf []proto.InFrame
|
||||
inSignal chan struct{} // size-1; pulsed when inBuf gains data
|
||||
inSeq uint64 // number of the last browser message taken
|
||||
|
||||
out chan proto.OutMessage // agent -> panel
|
||||
|
||||
@@ -52,6 +59,9 @@ func NewHub() *Hub {
|
||||
ctrlWaiters: map[string]chan struct{}{},
|
||||
sessions: map[string]*Session{},
|
||||
tasks: map[string]*taskEntry{},
|
||||
polls: map[string]int{},
|
||||
lastPoll: map[string]time.Time{},
|
||||
started: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,10 +92,18 @@ func (h *Hub) newSession(agentID string) *Session {
|
||||
return s
|
||||
}
|
||||
|
||||
// CreateSession registers a terminal session and queues it for the agent.
|
||||
func (h *Hub) CreateSession(agentID string) *Session {
|
||||
// CreateSession registers a terminal session and queues it for the agent. user
|
||||
// is the OS user to run the shell as ("" = the agent's own user).
|
||||
func (h *Hub) CreateSession(agentID, user string) *Session {
|
||||
s := h.newSession(agentID)
|
||||
h.enqueueJob(agentID, proto.ControlMessage{Type: proto.CtrlOpenTerminal, SessionID: s.ID})
|
||||
h.enqueueJob(agentID, proto.ControlMessage{Type: proto.CtrlOpenTerminal, SessionID: s.ID, User: user})
|
||||
return s
|
||||
}
|
||||
|
||||
// CreateSSH registers a terminal session the agent opens by SSH to t.
|
||||
func (h *Hub) CreateSSH(agentID string, t proto.SSHTarget) *Session {
|
||||
s := h.newSession(agentID)
|
||||
h.enqueueJob(agentID, proto.ControlMessage{Type: proto.CtrlOpenSSH, SessionID: s.ID, SSH: &t})
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -96,9 +114,56 @@ func (h *Hub) CreateForward(agentID string, port int) *Session {
|
||||
return s
|
||||
}
|
||||
|
||||
// WaitControl blocks until a job is queued for agentID or timeout elapses.
|
||||
func (h *Hub) WaitControl(agentID string, timeout time.Duration) proto.ControlMessage {
|
||||
deadline := time.After(timeout)
|
||||
// pollGrace is how long an agent stays online between two control polls: it
|
||||
// re-polls at once, so this only has to cover a slow round trip or one retry.
|
||||
const pollGrace = 10 * time.Second
|
||||
|
||||
// Online reports whether the agent is connected: it holds a control poll open
|
||||
// or has just finished one. ok is false while that cannot be told yet (right
|
||||
// after a start, before agents had time to come back).
|
||||
func (h *Hub) Online(agentID string) (online, ok bool) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if h.polls[agentID] > 0 {
|
||||
return true, true
|
||||
}
|
||||
if t, seen := h.lastPoll[agentID]; seen {
|
||||
return time.Since(t) < pollGrace, true
|
||||
}
|
||||
return false, time.Since(h.started) > pollHold+pollGrace
|
||||
}
|
||||
|
||||
// Forget drops what the hub keeps about a deleted agent.
|
||||
func (h *Hub) Forget(agentID string) {
|
||||
h.mu.Lock()
|
||||
delete(h.jobs, agentID)
|
||||
delete(h.ctrlWaiters, agentID)
|
||||
delete(h.lastPoll, agentID)
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
// WaitControl blocks until a job is queued for agentID, the timeout elapses
|
||||
// or the agent hangs up (done). The agent counts as online meanwhile.
|
||||
func (h *Hub) WaitControl(done <-chan struct{}, agentID string, timeout time.Duration) proto.ControlMessage {
|
||||
h.mu.Lock()
|
||||
h.polls[agentID]++
|
||||
h.mu.Unlock()
|
||||
defer func() {
|
||||
h.mu.Lock()
|
||||
if h.polls[agentID]--; h.polls[agentID] <= 0 {
|
||||
delete(h.polls, agentID)
|
||||
}
|
||||
// A poll cut off by the agent is not a sign of life.
|
||||
select {
|
||||
case <-done:
|
||||
default:
|
||||
h.lastPoll[agentID] = time.Now()
|
||||
}
|
||||
h.mu.Unlock()
|
||||
}()
|
||||
t := time.NewTimer(timeout)
|
||||
defer t.Stop()
|
||||
deadline := t.C
|
||||
for {
|
||||
h.mu.Lock()
|
||||
if q := h.jobs[agentID]; len(q) > 0 {
|
||||
@@ -118,6 +183,9 @@ func (h *Hub) WaitControl(agentID string, timeout time.Duration) proto.ControlMe
|
||||
case <-w:
|
||||
case <-deadline:
|
||||
return proto.ControlMessage{Type: proto.CtrlIdle}
|
||||
case <-done:
|
||||
// The job, if one comes, stays queued for the next poll.
|
||||
return proto.ControlMessage{Type: proto.CtrlIdle}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -190,6 +258,19 @@ func (s *Session) close() { s.closeOnce.Do(func() { close(s.closed) }) }
|
||||
// Done returns a channel closed when the session ends.
|
||||
func (s *Session) Done() <-chan struct{} { return s.closed }
|
||||
|
||||
// TakeSeq reports whether the browser message numbered seq is new, and takes
|
||||
// note of it. The browser sends them one at a time in rising order and sends
|
||||
// one again when it got no answer, so a number not above the last is a repeat.
|
||||
func (s *Session) TakeSeq(seq uint64) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if seq <= s.inSeq {
|
||||
return false
|
||||
}
|
||||
s.inSeq = seq
|
||||
return true
|
||||
}
|
||||
|
||||
// PushIn appends a downstream frame (panel -> agent) and wakes the agent poll.
|
||||
func (s *Session) PushIn(f proto.InFrame) {
|
||||
s.mu.Lock()
|
||||
@@ -201,6 +282,18 @@ func (s *Session) PushIn(f proto.InFrame) {
|
||||
}
|
||||
}
|
||||
|
||||
// Unpoll puts frames taken by PollIn back in front, for frames that could not
|
||||
// be delivered.
|
||||
func (s *Session) Unpoll(frames []proto.InFrame) {
|
||||
s.mu.Lock()
|
||||
s.inBuf = append(append([]proto.InFrame{}, frames...), s.inBuf...)
|
||||
s.mu.Unlock()
|
||||
select {
|
||||
case s.inSignal <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// PollIn drains queued downstream frames, blocking up to timeout for the first.
|
||||
func (s *Session) PollIn(timeout time.Duration) ([]proto.InFrame, bool) {
|
||||
deadline := time.After(timeout)
|
||||
@@ -238,14 +331,29 @@ func (s *Session) Out() <-chan proto.OutMessage { return s.out }
|
||||
|
||||
// PollOut drains queued upstream messages, blocking up to timeout for the first.
|
||||
func (s *Session) PollOut(timeout time.Duration) ([]proto.OutMessage, bool) {
|
||||
return s.PollOutUntil(nil, timeout)
|
||||
}
|
||||
|
||||
// PollOutUntil is PollOut that also gives up when done closes, taking
|
||||
// nothing: for a reader that has gone, so no output is lost on it.
|
||||
func (s *Session) PollOutUntil(done <-chan struct{}, timeout time.Duration) ([]proto.OutMessage, bool) {
|
||||
var batch []proto.OutMessage
|
||||
select {
|
||||
case m := <-s.out:
|
||||
batch = append(batch, m)
|
||||
case <-done:
|
||||
return nil, true
|
||||
case <-time.After(timeout):
|
||||
return nil, true
|
||||
case <-s.closed:
|
||||
return nil, false
|
||||
// Hand over output queued before the close (e.g. the reason a shell
|
||||
// failed to start) instead of dropping it.
|
||||
select {
|
||||
case m := <-s.out:
|
||||
batch = append(batch, m)
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
for {
|
||||
select {
|
||||
|
||||
@@ -0,0 +1,644 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/ed25519"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Passkeys (WebAuthn): an admin signs in with a key held by the device - a
|
||||
// fingerprint, the phone's lock, a hardware key - instead of a password. With
|
||||
// a key in place, the admin can turn the password off in their account.
|
||||
//
|
||||
// Attestation is not checked ("none"): the panel trusts any authenticator,
|
||||
// it only needs the public key and, at sign-in, a signature made with it.
|
||||
|
||||
var b64u = base64.RawURLEncoding
|
||||
|
||||
// challenges are the ones handed out and not yet used, a few minutes each.
|
||||
type challenges struct {
|
||||
mu sync.Mutex
|
||||
m map[string]challenge
|
||||
}
|
||||
|
||||
type challenge struct {
|
||||
adminID int64 // the admin registering a key; 0 for a sign-in
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
func (c *challenges) issue(adminID int64) string {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic("crypto/rand failed: " + err.Error())
|
||||
}
|
||||
id := b64u.EncodeToString(b)
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.m == nil {
|
||||
c.m = map[string]challenge{}
|
||||
}
|
||||
for k, v := range c.m {
|
||||
if time.Now().After(v.expires) {
|
||||
delete(c.m, k)
|
||||
}
|
||||
}
|
||||
c.m[id] = challenge{adminID: adminID, expires: time.Now().Add(5 * time.Minute)}
|
||||
return id
|
||||
}
|
||||
|
||||
// take uses up a challenge: true when it was issued, for this admin, and is
|
||||
// still fresh.
|
||||
func (c *challenges) take(id string, adminID int64) bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
ch, ok := c.m[id]
|
||||
delete(c.m, id)
|
||||
return ok && ch.adminID == adminID && time.Now().Before(ch.expires)
|
||||
}
|
||||
|
||||
// mainHost is the main address's host name, set when the server starts.
|
||||
var mainHost = func() string { return "" }
|
||||
|
||||
// rpID is the site the keys belong to: the host the browser is on, or the
|
||||
// main address's host when the browser is on it or under it - so the keys
|
||||
// made on lpra.example.com also sign in on direct.lpra.example.com.
|
||||
func rpID(r *http.Request) string {
|
||||
host := r.Host
|
||||
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||
host = h
|
||||
}
|
||||
if m := mainHost(); m != "" && strings.HasSuffix(host, "."+m) {
|
||||
return m
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// checkClientData verifies what the browser signed over: the kind of
|
||||
// ceremony, our challenge and our origin. A proxy in front may speak plain
|
||||
// http to us, so the origin is matched by host, and must be https unless the
|
||||
// panel is on this machine.
|
||||
func checkClientData(r *http.Request, raw []byte, typ string, adminID int64, ch *challenges) error {
|
||||
var cd struct {
|
||||
Type string `json:"type"`
|
||||
Challenge string `json:"challenge"`
|
||||
Origin string `json:"origin"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &cd); err != nil {
|
||||
return errors.New("ответ ключа не читается")
|
||||
}
|
||||
if cd.Type != typ {
|
||||
return errors.New("ответ ключа не того вида")
|
||||
}
|
||||
if !ch.take(cd.Challenge, adminID) {
|
||||
return errors.New("запрос устарел - попробуйте ещё раз")
|
||||
}
|
||||
u, err := url.Parse(cd.Origin)
|
||||
if err != nil || u.Host != r.Host {
|
||||
return errors.New("ключ подписал запрос для другого сайта")
|
||||
}
|
||||
local := u.Hostname() == "localhost" || u.Hostname() == "127.0.0.1" || u.Hostname() == "::1"
|
||||
if u.Scheme != "https" && !local {
|
||||
return errors.New("ключи доступа работают только по https")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// authData is the authenticator's part: rpIdHash, flags, counter, then the
|
||||
// new credential at registration.
|
||||
type authData struct {
|
||||
rpIDHash []byte
|
||||
flags byte
|
||||
signCount uint32
|
||||
credID []byte
|
||||
coseKey []byte
|
||||
}
|
||||
|
||||
func parseAuthData(b []byte, wantCred bool) (*authData, error) {
|
||||
if len(b) < 37 {
|
||||
return nil, errors.New("данные ключа слишком короткие")
|
||||
}
|
||||
ad := &authData{rpIDHash: b[:32], flags: b[32], signCount: binary.BigEndian.Uint32(b[33:37])}
|
||||
if !wantCred {
|
||||
return ad, nil
|
||||
}
|
||||
if ad.flags&0x40 == 0 || len(b) < 37+18 {
|
||||
return nil, errors.New("ключ не прислал себя")
|
||||
}
|
||||
n := int(binary.BigEndian.Uint16(b[53:55]))
|
||||
if len(b) < 55+n {
|
||||
return nil, errors.New("данные ключа обрезаны")
|
||||
}
|
||||
ad.credID = b[55 : 55+n]
|
||||
ad.coseKey = b[55+n:]
|
||||
return ad, nil
|
||||
}
|
||||
|
||||
func (ad *authData) check(r *http.Request) error {
|
||||
sum := sha256.Sum256([]byte(rpID(r)))
|
||||
if !bytes.Equal(ad.rpIDHash, sum[:]) {
|
||||
return errors.New("ключ создан для другого сайта")
|
||||
}
|
||||
if ad.flags&0x01 == 0 {
|
||||
return errors.New("ключ не подтвердил присутствие")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// coseToPKIX turns a COSE public key (ES256, RS256 or EdDSA) into PKIX DER.
|
||||
func coseToPKIX(raw []byte) ([]byte, error) {
|
||||
v, _, err := cborDecode(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m, ok := v.(map[any]any)
|
||||
if !ok {
|
||||
return nil, errors.New("ключ не в формате COSE")
|
||||
}
|
||||
num := func(k int64) int64 { n, _ := m[k].(int64); return n }
|
||||
bin := func(k int64) []byte { b, _ := m[k].([]byte); return b }
|
||||
var pub any
|
||||
switch num(1) { // kty
|
||||
case 2: // EC2
|
||||
x, y := bin(-2), bin(-3)
|
||||
if num(-1) != 1 || len(x) != 32 || len(y) != 32 {
|
||||
return nil, errors.New("поддерживается только кривая P-256")
|
||||
}
|
||||
k := &ecdsa.PublicKey{Curve: elliptic.P256(), X: new(big.Int).SetBytes(x), Y: new(big.Int).SetBytes(y)}
|
||||
if !k.Curve.IsOnCurve(k.X, k.Y) {
|
||||
return nil, errors.New("ключ не на кривой")
|
||||
}
|
||||
pub = k
|
||||
case 3: // RSA
|
||||
n, e := bin(-1), bin(-2)
|
||||
if len(n) < 256 || len(e) == 0 || len(e) > 4 {
|
||||
return nil, errors.New("неподходящий ключ RSA")
|
||||
}
|
||||
pub = &rsa.PublicKey{N: new(big.Int).SetBytes(n), E: int(new(big.Int).SetBytes(e).Int64())}
|
||||
case 1: // OKP
|
||||
x := bin(-2)
|
||||
if num(-1) != 6 || len(x) != ed25519.PublicKeySize {
|
||||
return nil, errors.New("поддерживается только Ed25519")
|
||||
}
|
||||
pub = ed25519.PublicKey(x)
|
||||
default:
|
||||
return nil, errors.New("неизвестный вид ключа")
|
||||
}
|
||||
return x509.MarshalPKIXPublicKey(pub)
|
||||
}
|
||||
|
||||
func verifySig(der, data, sig []byte) bool {
|
||||
pub, err := x509.ParsePKIXPublicKey(der)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
sum := sha256.Sum256(data)
|
||||
switch k := pub.(type) {
|
||||
case *ecdsa.PublicKey:
|
||||
return ecdsa.VerifyASN1(k, sum[:], sig)
|
||||
case *rsa.PublicKey:
|
||||
return rsa.VerifyPKCS1v15(k, crypto.SHA256, sum[:], sig) == nil
|
||||
case ed25519.PublicKey:
|
||||
return ed25519.Verify(k, data, sig)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ---------- CBOR, as much as WebAuthn needs ----------
|
||||
|
||||
func cborDecode(b []byte) (any, []byte, error) {
|
||||
r := &cborReader{b: b}
|
||||
v, err := r.item(0)
|
||||
return v, r.b[r.i:], err
|
||||
}
|
||||
|
||||
type cborReader struct {
|
||||
b []byte
|
||||
i int
|
||||
}
|
||||
|
||||
var errCBOR = errors.New("данные ключа повреждены")
|
||||
|
||||
func (r *cborReader) arg(info byte) (uint64, error) {
|
||||
size := map[byte]int{24: 1, 25: 2, 26: 4, 27: 8}
|
||||
if info < 24 {
|
||||
return uint64(info), nil
|
||||
}
|
||||
n, ok := size[info]
|
||||
if !ok || r.i+n > len(r.b) {
|
||||
return 0, errCBOR
|
||||
}
|
||||
var v uint64
|
||||
for _, c := range r.b[r.i : r.i+n] {
|
||||
v = v<<8 | uint64(c)
|
||||
}
|
||||
r.i += n
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (r *cborReader) take(n uint64) ([]byte, error) {
|
||||
if n > uint64(len(r.b)-r.i) {
|
||||
return nil, errCBOR
|
||||
}
|
||||
out := r.b[r.i : r.i+int(n)]
|
||||
r.i += int(n)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *cborReader) item(depth int) (any, error) {
|
||||
if r.i >= len(r.b) || depth > 16 {
|
||||
return nil, errCBOR
|
||||
}
|
||||
ib := r.b[r.i]
|
||||
r.i++
|
||||
major, info := ib>>5, ib&31
|
||||
if major == 7 {
|
||||
switch info {
|
||||
case 20:
|
||||
return false, nil
|
||||
case 21:
|
||||
return true, nil
|
||||
case 22, 23:
|
||||
return nil, nil
|
||||
}
|
||||
_, err := r.arg(info) // a float: not used, skipped
|
||||
return nil, err
|
||||
}
|
||||
n, err := r.arg(info)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch major {
|
||||
case 0:
|
||||
return int64(n), nil
|
||||
case 1:
|
||||
return -1 - int64(n), nil
|
||||
case 2:
|
||||
return r.take(n)
|
||||
case 3:
|
||||
s, err := r.take(n)
|
||||
return string(s), err
|
||||
case 4:
|
||||
if n > 1024 {
|
||||
return nil, errCBOR
|
||||
}
|
||||
arr := make([]any, 0, n)
|
||||
for k := uint64(0); k < n; k++ {
|
||||
v, err := r.item(depth + 1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
arr = append(arr, v)
|
||||
}
|
||||
return arr, nil
|
||||
case 5:
|
||||
if n > 1024 {
|
||||
return nil, errCBOR
|
||||
}
|
||||
m := make(map[any]any, n)
|
||||
for k := uint64(0); k < n; k++ {
|
||||
key, err := r.item(depth + 1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v, err := r.item(depth + 1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch key.(type) {
|
||||
case int64, string:
|
||||
m[key] = v
|
||||
default:
|
||||
return nil, errCBOR
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
case 6:
|
||||
return r.item(depth + 1) // a tag: its content
|
||||
}
|
||||
return nil, errCBOR
|
||||
}
|
||||
|
||||
// ---------- handlers ----------
|
||||
|
||||
func credParams() []map[string]any {
|
||||
return []map[string]any{
|
||||
{"type": "public-key", "alg": -7}, // ES256
|
||||
{"type": "public-key", "alg": -8}, // EdDSA
|
||||
{"type": "public-key", "alg": -257}, // RS256
|
||||
}
|
||||
}
|
||||
|
||||
func userHandle(id int64) string {
|
||||
b := make([]byte, 8)
|
||||
binary.BigEndian.PutUint64(b, uint64(id))
|
||||
return b64u.EncodeToString(b)
|
||||
}
|
||||
|
||||
// GET /api/panel/account - who is signed in, and their passkeys.
|
||||
func (s *Server) handleAccount(w http.ResponseWriter, r *http.Request) {
|
||||
a := s.currentAdmin(r)
|
||||
keys, _ := s.db.ListAdminKeys(a.ID)
|
||||
list := []map[string]any{}
|
||||
for _, k := range keys {
|
||||
list = append(list, map[string]any{"id": k.ID, "name": k.Name, "created": k.CreatedAt, "last_used": k.LastUsed})
|
||||
}
|
||||
writeJSON(w, map[string]any{"username": a.Username, "keys": list, "password_off": s.db.PasswordOff(a.ID), "pin": s.db.AdminPin(a.ID) != "",
|
||||
"pw_locked": a.PwLocked, "admin": a.IsAdmin()})
|
||||
}
|
||||
|
||||
// POST /api/panel/account/password/change - a new password, given the
|
||||
// current one.
|
||||
func (s *Server) handlePasswordChange(w http.ResponseWriter, r *http.Request) {
|
||||
a := s.currentAdmin(r)
|
||||
var in struct {
|
||||
Current string `json:"current"`
|
||||
New string `json:"new"`
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&in); err != nil {
|
||||
writeJSON(w, map[string]any{"ok": false, "error": "плохой запрос"})
|
||||
return
|
||||
}
|
||||
ip := clientIP(r)
|
||||
if s.limiter.blocked(ip) {
|
||||
writeJSON(w, map[string]any{"ok": false, "error": "Слишком много попыток. Подождите немного."})
|
||||
return
|
||||
}
|
||||
if !checkPassword(a.PassHash, in.Current) {
|
||||
s.limiter.fin(ip, false)
|
||||
writeJSON(w, map[string]any{"ok": false, "error": "текущий пароль не тот"})
|
||||
return
|
||||
}
|
||||
if len(in.New) < 8 {
|
||||
writeJSON(w, map[string]any{"ok": false, "error": "новый пароль - не короче 8 символов"})
|
||||
return
|
||||
}
|
||||
hash, err := hashPassword(in.New)
|
||||
if err == nil {
|
||||
err = s.db.SetAdminPassword(a.ID, hash)
|
||||
}
|
||||
if err != nil {
|
||||
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
s.limiter.fin(ip, true)
|
||||
s.db.Audit(a.ID, "", "password_change")
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// POST /api/panel/account/password {"off": bool} - turn signing in with the
|
||||
// password off or on again. Off only with a passkey to sign in with.
|
||||
func (s *Server) handlePasswordToggle(w http.ResponseWriter, r *http.Request) {
|
||||
a := s.currentAdmin(r)
|
||||
var in struct {
|
||||
Off bool `json:"off"`
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<10)).Decode(&in); err != nil {
|
||||
writeJSON(w, map[string]any{"ok": false, "error": "плохой запрос"})
|
||||
return
|
||||
}
|
||||
if a.PwLocked {
|
||||
writeJSON(w, map[string]any{"ok": false, "error": "вход по паролю закрепил администратор"})
|
||||
return
|
||||
}
|
||||
if in.Off && s.db.CountAdminKeys(a.ID) == 0 {
|
||||
writeJSON(w, map[string]any{"ok": false, "error": "сначала добавьте ключ доступа"})
|
||||
return
|
||||
}
|
||||
if err := s.db.SetPasswordOff(a.ID, in.Off); err != nil {
|
||||
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
action := "password_on"
|
||||
if in.Off {
|
||||
action = "password_off"
|
||||
}
|
||||
s.db.Audit(a.ID, "", action)
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// POST /api/panel/account/keys/begin - options to make a new passkey with.
|
||||
func (s *Server) handleKeyRegisterBegin(w http.ResponseWriter, r *http.Request) {
|
||||
a := s.currentAdmin(r)
|
||||
keys, _ := s.db.ListAdminKeys(a.ID)
|
||||
exclude := []map[string]any{}
|
||||
for _, k := range keys {
|
||||
exclude = append(exclude, map[string]any{"type": "public-key", "id": b64u.EncodeToString(k.CredID)})
|
||||
}
|
||||
writeJSON(w, map[string]any{"ok": true, "options": map[string]any{
|
||||
"challenge": s.challenges.issue(a.ID),
|
||||
"rp": map[string]any{"id": rpID(r), "name": "LPRA"},
|
||||
"user": map[string]any{"id": userHandle(a.ID), "name": a.Username, "displayName": a.Username},
|
||||
"pubKeyCredParams": credParams(),
|
||||
"excludeCredentials": exclude,
|
||||
"authenticatorSelection": map[string]any{
|
||||
"residentKey": "preferred", "userVerification": "preferred",
|
||||
},
|
||||
"attestation": "none",
|
||||
"timeout": 120000,
|
||||
}})
|
||||
}
|
||||
|
||||
// POST /api/panel/account/keys/finish - keep the passkey the browser made.
|
||||
func (s *Server) handleKeyRegisterFinish(w http.ResponseWriter, r *http.Request) {
|
||||
a := s.currentAdmin(r)
|
||||
var in struct {
|
||||
Name string `json:"name"`
|
||||
ClientDataJSON string `json:"clientDataJSON"`
|
||||
AttestationObject string `json:"attestationObject"`
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&in); err != nil {
|
||||
writeJSON(w, map[string]any{"ok": false, "error": "плохой запрос"})
|
||||
return
|
||||
}
|
||||
fail := func(err error) { writeJSON(w, map[string]any{"ok": false, "error": err.Error()}) }
|
||||
cd, err1 := b64u.DecodeString(in.ClientDataJSON)
|
||||
att, err2 := b64u.DecodeString(in.AttestationObject)
|
||||
if err1 != nil || err2 != nil {
|
||||
fail(errors.New("плохой запрос"))
|
||||
return
|
||||
}
|
||||
if err := checkClientData(r, cd, "webauthn.create", a.ID, &s.challenges); err != nil {
|
||||
fail(err)
|
||||
return
|
||||
}
|
||||
v, _, err := cborDecode(att)
|
||||
m, ok := v.(map[any]any)
|
||||
if err != nil || !ok {
|
||||
fail(errCBOR)
|
||||
return
|
||||
}
|
||||
raw, _ := m["authData"].([]byte)
|
||||
ad, err := parseAuthData(raw, true)
|
||||
if err != nil {
|
||||
fail(err)
|
||||
return
|
||||
}
|
||||
if err := ad.check(r); err != nil {
|
||||
fail(err)
|
||||
return
|
||||
}
|
||||
der, err := coseToPKIX(ad.coseKey)
|
||||
if err != nil {
|
||||
fail(err)
|
||||
return
|
||||
}
|
||||
if k, _ := s.db.AdminKeyByCredID(ad.credID); k != nil {
|
||||
fail(errors.New("этот ключ уже добавлен"))
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(in.Name)
|
||||
if name == "" {
|
||||
name = "ключ " + time.Now().Format("02.01.2006")
|
||||
}
|
||||
if len(name) > 60 {
|
||||
name = name[:60]
|
||||
}
|
||||
if err := s.db.AddAdminKey(AdminKey{AdminID: a.ID, CredID: ad.credID, PublicKey: der, SignCount: ad.signCount, Name: name}); err != nil {
|
||||
fail(err)
|
||||
return
|
||||
}
|
||||
s.db.Audit(a.ID, "", "passkey_add:"+name)
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// POST /api/panel/account/keys/{id}/delete
|
||||
func (s *Server) handleKeyDelete(w http.ResponseWriter, r *http.Request) {
|
||||
a := s.currentAdmin(r)
|
||||
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err := s.db.DeleteAdminKey(a.ID, id); err != nil {
|
||||
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
s.db.Audit(a.ID, "", "passkey_delete:"+strconv.FormatInt(id, 10))
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// POST /api/login/key/begin - options to sign in with a passkey. With a
|
||||
// username, its keys are named, for keys that do not keep the account
|
||||
// themselves.
|
||||
func (s *Server) handleKeyLoginBegin(w http.ResponseWriter, r *http.Request) {
|
||||
if s.limiter.blocked(clientIP(r)) {
|
||||
writeJSON(w, map[string]any{"ok": false, "error": "Слишком много попыток. Подождите немного."})
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
Username string `json:"username"`
|
||||
}
|
||||
_ = json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&in)
|
||||
allow := []map[string]any{}
|
||||
if u := strings.TrimSpace(in.Username); u != "" {
|
||||
if a, _ := s.db.AdminByUsername(u); a != nil {
|
||||
keys, _ := s.db.ListAdminKeys(a.ID)
|
||||
for _, k := range keys {
|
||||
allow = append(allow, map[string]any{"type": "public-key", "id": b64u.EncodeToString(k.CredID)})
|
||||
}
|
||||
}
|
||||
}
|
||||
writeJSON(w, map[string]any{"ok": true, "options": map[string]any{
|
||||
"challenge": s.challenges.issue(0),
|
||||
"rpId": rpID(r),
|
||||
"allowCredentials": allow,
|
||||
"userVerification": "preferred",
|
||||
"timeout": 120000,
|
||||
}})
|
||||
}
|
||||
|
||||
// POST /api/login/key/finish - check the signature and sign the admin in.
|
||||
func (s *Server) handleKeyLoginFinish(w http.ResponseWriter, r *http.Request) {
|
||||
ip := clientIP(r)
|
||||
if s.limiter.blocked(ip) {
|
||||
writeJSON(w, map[string]any{"ok": false, "error": "Слишком много попыток. Подождите немного."})
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
ID string `json:"id"`
|
||||
ClientDataJSON string `json:"clientDataJSON"`
|
||||
AuthenticatorData string `json:"authenticatorData"`
|
||||
Signature string `json:"signature"`
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&in); err != nil {
|
||||
writeJSON(w, map[string]any{"ok": false, "error": "плохой запрос"})
|
||||
return
|
||||
}
|
||||
fail := func(msg string) {
|
||||
s.limiter.fin(ip, false)
|
||||
writeJSON(w, map[string]any{"ok": false, "error": msg})
|
||||
}
|
||||
credID, e1 := b64u.DecodeString(in.ID)
|
||||
cd, e2 := b64u.DecodeString(in.ClientDataJSON)
|
||||
rawAD, e3 := b64u.DecodeString(in.AuthenticatorData)
|
||||
sig, e4 := b64u.DecodeString(in.Signature)
|
||||
if e1 != nil || e2 != nil || e3 != nil || e4 != nil {
|
||||
fail("плохой запрос")
|
||||
return
|
||||
}
|
||||
k, _ := s.db.AdminKeyByCredID(credID)
|
||||
if k == nil {
|
||||
fail("этот ключ здесь не привязан ни к одному аккаунту")
|
||||
return
|
||||
}
|
||||
if err := checkClientData(r, cd, "webauthn.get", 0, &s.challenges); err != nil {
|
||||
fail(err.Error())
|
||||
return
|
||||
}
|
||||
ad, err := parseAuthData(rawAD, false)
|
||||
if err == nil {
|
||||
err = ad.check(r)
|
||||
}
|
||||
if err != nil {
|
||||
fail(err.Error())
|
||||
return
|
||||
}
|
||||
cdHash := sha256.Sum256(cd)
|
||||
if !verifySig(k.PublicKey, append(append([]byte{}, rawAD...), cdHash[:]...), sig) {
|
||||
fail("подпись ключа не сошлась")
|
||||
return
|
||||
}
|
||||
// A counter that goes back means a copy of the key is in use.
|
||||
if ad.signCount != 0 && k.SignCount != 0 && ad.signCount <= k.SignCount {
|
||||
fail("ключ похож на копию: счётчик не вырос")
|
||||
return
|
||||
}
|
||||
_ = s.db.UseAdminKey(k.ID, ad.signCount)
|
||||
s.limiter.fin(ip, true)
|
||||
if err := s.setSession(w, k.AdminID); err != nil {
|
||||
writeJSON(w, map[string]any{"ok": false, "error": "внутренняя ошибка"})
|
||||
return
|
||||
}
|
||||
s.db.Audit(k.AdminID, "", "login_passkey:"+k.Name)
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// POST /settings/admins/keys/reset - drop all passkeys of an admin, who signs
|
||||
// in with the password again: for one who lost theirs.
|
||||
func (s *Server) handleResetAdminKeys(w http.ResponseWriter, r *http.Request) {
|
||||
if !checkCSRF(r) {
|
||||
http.Error(w, "bad csrf", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
id, _ := strconv.ParseInt(r.FormValue("id"), 10, 64)
|
||||
_ = s.db.DeleteAdminKeys(id)
|
||||
if a := s.currentAdmin(r); a != nil {
|
||||
s.db.Audit(a.ID, "", fmt.Sprintf("passkeys_reset:%d", id))
|
||||
}
|
||||
http.Redirect(w, r, "/settings", http.StatusSeeOther)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// The PIN: four digits an admin can set, asked for by the page in every new
|
||||
// browser tab (a reload of the same tab does not ask again). The server
|
||||
// checks it; five wrong ones in a row end the session.
|
||||
|
||||
var pinRe = regexp.MustCompile(`^[0-9]{4}$`)
|
||||
|
||||
// pinMisses counts wrong PINs per session.
|
||||
type pinMisses struct {
|
||||
mu sync.Mutex
|
||||
m map[string]int
|
||||
}
|
||||
|
||||
func (p *pinMisses) miss(sid string) int {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.m == nil {
|
||||
p.m = map[string]int{}
|
||||
}
|
||||
p.m[sid]++
|
||||
return p.m[sid]
|
||||
}
|
||||
|
||||
func (p *pinMisses) clear(sid string) {
|
||||
p.mu.Lock()
|
||||
delete(p.m, sid)
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
// GET /api/panel/pin - whether the admin has a PIN to ask for.
|
||||
func (s *Server) handlePinState(w http.ResponseWriter, r *http.Request) {
|
||||
a := s.currentAdmin(r)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
writeJSON(w, map[string]any{"pin": s.db.AdminPin(a.ID) != ""})
|
||||
}
|
||||
|
||||
// POST /api/panel/pin/check {"pin"} - the PIN typed in a new tab.
|
||||
func (s *Server) handlePinCheck(w http.ResponseWriter, r *http.Request) {
|
||||
a := s.currentAdmin(r)
|
||||
var in struct {
|
||||
Pin string `json:"pin"`
|
||||
}
|
||||
_ = json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<10)).Decode(&in)
|
||||
h := s.db.AdminPin(a.ID)
|
||||
c, _ := r.Cookie(sessionCookie)
|
||||
if h == "" || (pinRe.MatchString(in.Pin) && checkPassword(h, in.Pin)) {
|
||||
s.pinMisses.clear(c.Value)
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
return
|
||||
}
|
||||
if n := s.pinMisses.miss(c.Value); n >= 5 {
|
||||
s.pinMisses.clear(c.Value)
|
||||
s.clearSession(w, r)
|
||||
s.db.Audit(a.ID, "", "pin_lockout")
|
||||
writeJSON(w, map[string]any{"ok": false, "out": true, "error": "слишком много неверных попыток"})
|
||||
return
|
||||
} else {
|
||||
writeJSON(w, map[string]any{"ok": false, "left": 5 - n, "error": "неверный PIN"})
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/panel/account/pin {"pin", "current"} - set or change the PIN;
|
||||
// with pin empty, remove it. A PIN in place is needed to change or drop it.
|
||||
func (s *Server) handlePinSet(w http.ResponseWriter, r *http.Request) {
|
||||
a := s.currentAdmin(r)
|
||||
var in struct {
|
||||
Pin string `json:"pin"`
|
||||
Current string `json:"current"`
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<10)).Decode(&in); err != nil {
|
||||
writeJSON(w, map[string]any{"ok": false, "error": "плохой запрос"})
|
||||
return
|
||||
}
|
||||
if h := s.db.AdminPin(a.ID); h != "" && !checkPassword(h, in.Current) {
|
||||
writeJSON(w, map[string]any{"ok": false, "error": "текущий PIN не тот"})
|
||||
return
|
||||
}
|
||||
hash := ""
|
||||
if in.Pin != "" {
|
||||
if !pinRe.MatchString(in.Pin) {
|
||||
writeJSON(w, map[string]any{"ok": false, "error": "PIN - ровно 4 цифры"})
|
||||
return
|
||||
}
|
||||
var err error
|
||||
if hash, err = hashPassword(in.Pin); err != nil {
|
||||
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := s.db.SetAdminPin(a.ID, hash); err != nil {
|
||||
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
action := "pin_set"
|
||||
if hash == "" {
|
||||
action = "pin_remove"
|
||||
}
|
||||
s.db.Audit(a.ID, "", action)
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
}
|
||||
+241
-25
@@ -1,7 +1,9 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"embed"
|
||||
"encoding/hex"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"log"
|
||||
@@ -9,6 +11,8 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
texttemplate "text/template"
|
||||
)
|
||||
|
||||
@@ -24,7 +28,8 @@ type Options struct {
|
||||
// nginx TLS); set false only for plain-HTTP local testing.
|
||||
SecureCookies bool
|
||||
// TunnelAddr is the listen address for the direct TCP+UDP forward tunnel
|
||||
// (default ":8446"). Empty disables direct forwarding.
|
||||
// Empty disables direct forwarding. The port can be overridden from the
|
||||
// settings page.
|
||||
TunnelAddr string
|
||||
}
|
||||
|
||||
@@ -33,12 +38,33 @@ type Server struct {
|
||||
db *DB
|
||||
hub *Hub
|
||||
fwd *forwardManager
|
||||
tun *tunnelHub
|
||||
udp *udpRelay
|
||||
ufw *ufwManager
|
||||
tmpl *template.Template
|
||||
limiter *loginLimiter
|
||||
distDir string
|
||||
secureCookies bool
|
||||
installTmpl *texttemplate.Template
|
||||
|
||||
// Direct tunnel: bind host and port of the -tunnel address, and the port
|
||||
// currently listened on (the settings override, else the default).
|
||||
// tunnelDefault 0 means the tunnel is disabled.
|
||||
tunnelBind string
|
||||
tunnelDefault int
|
||||
tunnelMu sync.Mutex // serializes port changes
|
||||
tunnelPort atomic.Int64
|
||||
|
||||
stats statCache // last load sample per agent, for the status page
|
||||
relays speedRelays // speed tests that run through the browser
|
||||
meters speedMeters // byte counts of the speed tests under way
|
||||
xfers xfers // files on their way between a browser and an agent
|
||||
ship shipping // files on their way from one machine to another
|
||||
viaDirectA sync.Map // agent id -> true while it reaches the server at the direct address
|
||||
challenges challenges // passkey challenges handed out
|
||||
wsAgents wsAgents // agents on their WebSocket now
|
||||
pinMisses pinMisses // wrong PINs per session
|
||||
assets map[string]string // static file -> hash of its content
|
||||
}
|
||||
|
||||
// New builds the server, parsing embedded templates.
|
||||
@@ -46,7 +72,15 @@ func New(opts Options) (*Server, error) {
|
||||
if err := opts.DB.ensureDefaults(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tmpl, err := template.ParseFS(webFS, "web/templates/*.html")
|
||||
mainHost = func() string { return directHost(opts.DB.HostAddress()) }
|
||||
staticFS, _ := fs.Sub(webFS, "web/static")
|
||||
assets := assetHashes(staticFS)
|
||||
funcs := template.FuncMap{
|
||||
// asset is a static file's URL with its content hash, so a new
|
||||
// release is a new URL that no browser or proxy has cached.
|
||||
"asset": func(name string) string { return "/static/" + name + "?v=" + assets[name] },
|
||||
}
|
||||
tmpl, err := template.New("").Funcs(funcs).ParseFS(webFS, "web/templates/*.html")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -65,26 +99,88 @@ func New(opts Options) (*Server, error) {
|
||||
db: opts.DB,
|
||||
hub: hub,
|
||||
fwd: newForwardManager(hub, tun, udp),
|
||||
ufw: newUfwManager(opts.DB, portFromAddr(opts.TunnelAddr)),
|
||||
tun: tun,
|
||||
udp: udp,
|
||||
tmpl: tmpl,
|
||||
limiter: newLoginLimiter(),
|
||||
distDir: opts.DistDir,
|
||||
secureCookies: opts.SecureCookies,
|
||||
installTmpl: installTmpl,
|
||||
assets: assets,
|
||||
}
|
||||
// Start the direct forward tunnel (TCP mux + UDP) on 8446.
|
||||
s.ufw = newUfwManager(opts.DB, s.TunnelPort)
|
||||
// Start the direct forward tunnel (TCP mux + UDP), on the port from the
|
||||
// settings page if one is set there.
|
||||
if opts.TunnelAddr != "" {
|
||||
if err := tun.listen(opts.TunnelAddr); err != nil {
|
||||
log.Printf("tunnel tcp listen %s: %v", opts.TunnelAddr, err)
|
||||
s.tunnelBind, s.tunnelDefault = splitAddr(opts.TunnelAddr)
|
||||
}
|
||||
if s.tunnelDefault > 0 {
|
||||
port := s.tunnelDefault
|
||||
if p := s.db.TunnelPort(); p > 0 {
|
||||
port = p
|
||||
}
|
||||
if err := udp.listen(opts.TunnelAddr); err != nil {
|
||||
log.Printf("tunnel udp listen %s: %v", opts.TunnelAddr, err)
|
||||
s.tunnelPort.Store(int64(port))
|
||||
addr := s.tunnelListenAddr(port)
|
||||
if err := tun.listen(addr); err != nil {
|
||||
log.Printf("tunnel tcp listen %s: %v", addr, err)
|
||||
}
|
||||
if err := udp.listen(addr); err != nil {
|
||||
log.Printf("tunnel udp listen %s: %v", addr, err)
|
||||
}
|
||||
}
|
||||
s.restoreForwards()
|
||||
go s.ufw.sync()
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// TunnelPort is the port the direct tunnel listens on (0 = disabled).
|
||||
func (s *Server) TunnelPort() int { return int(s.tunnelPort.Load()) }
|
||||
|
||||
func (s *Server) tunnelListenAddr(port int) string {
|
||||
return net.JoinHostPort(s.tunnelBind, strconv.Itoa(port))
|
||||
}
|
||||
|
||||
// tunnelAdvert is the tunnel address handed to agents as "host:port". The host
|
||||
// is the one set in Settings, else the direct address's (the main one may be
|
||||
// behind a proxy that carries no raw TCP), else empty: agents then use the
|
||||
// panel's host.
|
||||
func (s *Server) tunnelAdvert() string {
|
||||
port := s.TunnelPort()
|
||||
if port == 0 {
|
||||
return ""
|
||||
}
|
||||
host := s.db.TunnelHost()
|
||||
if host == "" {
|
||||
host = directHost(s.db.DirectAddress())
|
||||
}
|
||||
return net.JoinHostPort(host, strconv.Itoa(port))
|
||||
}
|
||||
|
||||
// setTunnelPort moves the direct tunnel to another port. Connected agents are
|
||||
// dropped and redial the new port once their next poll tells them about it.
|
||||
func (s *Server) setTunnelPort(port int) error {
|
||||
s.tunnelMu.Lock()
|
||||
defer s.tunnelMu.Unlock()
|
||||
old := s.TunnelPort()
|
||||
if old == 0 || port == old {
|
||||
return nil
|
||||
}
|
||||
addr := s.tunnelListenAddr(port)
|
||||
if err := s.tun.listen(addr); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.udp.listen(addr); err != nil {
|
||||
// Keep TCP and UDP on the same port: go back to the old one.
|
||||
if rerr := s.tun.listen(s.tunnelListenAddr(old)); rerr != nil {
|
||||
log.Printf("tunnel tcp listen %s: %v", s.tunnelListenAddr(old), rerr)
|
||||
}
|
||||
return err
|
||||
}
|
||||
s.tunnelPort.Store(int64(port))
|
||||
go s.ufw.sync()
|
||||
return nil
|
||||
}
|
||||
|
||||
// restoreForwards re-opens the host listeners for all saved forward rules.
|
||||
func (s *Server) restoreForwards() {
|
||||
rules, err := s.db.ListForwardRules()
|
||||
@@ -93,6 +189,12 @@ func (s *Server) restoreForwards() {
|
||||
return
|
||||
}
|
||||
for _, r := range rules {
|
||||
// Rules of an agent deleted by an older version: drop them.
|
||||
if a, err := s.db.GetAgent(r.AgentID); err == nil && a == nil {
|
||||
log.Printf("forward rule %d: agent %s is gone, removing", r.ID, r.AgentID)
|
||||
s.removeForward(r.ID)
|
||||
continue
|
||||
}
|
||||
if err := s.fwd.open(r); err != nil {
|
||||
log.Printf("restore forward rule %d (%s:%d): %v", r.ID, r.ListenAddr, r.HostPort, err)
|
||||
}
|
||||
@@ -105,7 +207,7 @@ func (s *Server) Handler() http.Handler {
|
||||
|
||||
// Static assets (xterm.js etc.) from the embedded FS.
|
||||
staticFS, _ := fs.Sub(webFS, "web/static")
|
||||
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
|
||||
mux.Handle("GET /static/", http.StripPrefix("/static/", staticHandler(staticFS, s.assets)))
|
||||
|
||||
// Public: install script + agent binary downloads.
|
||||
mux.HandleFunc("GET /install.sh", s.handleInstallScript)
|
||||
@@ -120,34 +222,73 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("GET /login", s.handleLoginForm)
|
||||
mux.HandleFunc("POST /login", s.handleLoginSubmit)
|
||||
mux.HandleFunc("POST /logout", s.handleLogout)
|
||||
mux.HandleFunc("POST /api/login/key/begin", s.handleKeyLoginBegin)
|
||||
mux.HandleFunc("POST /api/login/key/finish", s.handleKeyLoginFinish)
|
||||
|
||||
// Panel (admin-only).
|
||||
mux.Handle("GET /{$}", s.requireAdmin(s.handleDashboard))
|
||||
mux.Handle("GET /agents/{id}/terminal", s.requireAdmin(s.handleTerminalPage))
|
||||
mux.Handle("GET /agents/{id}/files", s.requireAdmin(s.handleFilesPage))
|
||||
mux.Handle("GET /agents/{id}/forwards", s.requireAdmin(s.handleForwardsPage))
|
||||
mux.Handle("GET /agents/{id}/status", s.requireAdmin(s.handleStatusPage))
|
||||
mux.Handle("GET /agents/{id}/edit", s.requireAdmin(s.handleAgentEdit))
|
||||
mux.Handle("POST /agents/{id}/rename", s.requireAdmin(s.handleAgentRename))
|
||||
mux.Handle("POST /agents/{id}/update", s.requireAdmin(s.handleAgentUpdate))
|
||||
mux.Handle("POST /agents/{id}/delete", s.requireAdmin(s.handleAgentDelete))
|
||||
mux.Handle("GET /settings", s.requireAdmin(s.handleSettings))
|
||||
mux.Handle("POST /settings/host", s.requireAdmin(s.handleSetHost))
|
||||
mux.Handle("POST /settings/secret/rotate", s.requireAdmin(s.handleRotateSecret))
|
||||
mux.Handle("POST /settings/admins/add", s.requireAdmin(s.handleAddAdmin))
|
||||
mux.Handle("POST /settings/admins/delete", s.requireAdmin(s.handleDeleteAdmin))
|
||||
mux.Handle("POST /settings/ufw/toggle", s.requireAdmin(s.handleUfwToggle))
|
||||
mux.Handle("POST /settings/ufw/revoke", s.requireAdmin(s.handleUfwRevoke))
|
||||
mux.Handle("POST /agents/{id}/user", s.requireAdmin(s.handleAgentSetUser))
|
||||
mux.Handle("POST /agents/{id}/transport", s.requireAdmin(s.handleAgentTransport))
|
||||
mux.Handle("POST /agents/{id}/users/add", s.requireAdmin(s.handleAgentAddUser))
|
||||
mux.Handle("POST /agents/{id}/delete", s.adminsOnly(s.handleAgentDelete))
|
||||
mux.Handle("GET /settings", s.adminsOnly(s.handleSettings))
|
||||
mux.Handle("POST /settings/host", s.adminsOnly(s.handleSetHost))
|
||||
mux.Handle("POST /settings/tunnel", s.adminsOnly(s.handleSetTunnel))
|
||||
mux.Handle("POST /settings/secret/rotate", s.adminsOnly(s.handleRotateSecret))
|
||||
mux.Handle("POST /settings/install/code", s.adminsOnly(s.handleInstallCode))
|
||||
mux.Handle("POST /settings/admins/add", s.adminsOnly(s.handleAddAdmin))
|
||||
mux.Handle("POST /settings/admins/delete", s.adminsOnly(s.handleDeleteAdmin))
|
||||
mux.Handle("GET /api/panel/users/{id}/agents", s.adminsOnly(s.handleUserAgents))
|
||||
mux.Handle("POST /api/panel/users/{id}", s.adminsOnly(s.handleUserEdit))
|
||||
mux.Handle("POST /settings/admins/keys/reset", s.adminsOnly(s.handleResetAdminKeys))
|
||||
mux.Handle("GET /api/panel/account", s.requireAdmin(s.handleAccount))
|
||||
mux.Handle("POST /api/panel/account/keys/begin", s.requireAdmin(s.handleKeyRegisterBegin))
|
||||
mux.Handle("POST /api/panel/account/keys/finish", s.requireAdmin(s.handleKeyRegisterFinish))
|
||||
mux.Handle("POST /api/panel/account/keys/{id}/delete", s.requireAdmin(s.handleKeyDelete))
|
||||
mux.Handle("POST /api/panel/account/password", s.requireAdmin(s.handlePasswordToggle))
|
||||
mux.Handle("POST /api/panel/account/pin", s.requireAdmin(s.handlePinSet))
|
||||
mux.Handle("GET /api/panel/pin", s.requireAdmin(s.handlePinState))
|
||||
mux.Handle("POST /api/panel/pin/check", s.requireAdmin(s.handlePinCheck))
|
||||
mux.Handle("POST /api/panel/account/password/change", s.requireAdmin(s.handlePasswordChange))
|
||||
mux.Handle("POST /settings/ufw/toggle", s.adminsOnly(s.handleUfwToggle))
|
||||
mux.Handle("POST /settings/ufw/add", s.adminsOnly(s.handleUfwAdd))
|
||||
mux.Handle("POST /settings/ufw/revoke", s.adminsOnly(s.handleUfwRevoke))
|
||||
|
||||
// Panel terminal over HTTP long-polling (admin-only).
|
||||
mux.Handle("POST /api/panel/term/{agentID}/open", s.requireAdmin(s.handlePanelOpen))
|
||||
mux.Handle("GET /api/panel/term/{sid}/out", s.requireAdmin(s.handlePanelOut))
|
||||
mux.Handle("POST /api/panel/term/{sid}/in", s.requireAdmin(s.handlePanelIn))
|
||||
mux.Handle("GET /api/panel/term/{sid}/ws", s.requireAdmin(s.handlePanelTermWS))
|
||||
mux.Handle("POST /api/panel/term/{sid}/close", s.requireAdmin(s.handlePanelClose))
|
||||
|
||||
// Panel file manager (admin-only).
|
||||
mux.Handle("GET /api/panel/agents/{id}/fs/download", s.requireAdmin(s.handleFSDownload))
|
||||
mux.Handle("POST /api/panel/agents/{id}/fs/upload", s.requireAdmin(s.handleFSUploadStart))
|
||||
mux.Handle("PUT /api/panel/fs/xfer/{xid}/{i}", s.requireAdmin(s.handleXferPut))
|
||||
mux.Handle("POST /api/panel/fs/xfer/{xid}/cancel", s.requireAdmin(s.handleXferCancel))
|
||||
mux.Handle("GET /api/panel/fs/xfer/{xid}/result", s.requireAdmin(s.handleXferResult))
|
||||
mux.Handle("POST /api/panel/agents/{id}/fs/ship", s.requireAdmin(s.handleFSShip))
|
||||
mux.Handle("GET /api/panel/fs/job/{jid}", s.requireAdmin(s.handleShipJob))
|
||||
mux.Handle("POST /api/panel/fs/job/{jid}/cancel", s.requireAdmin(s.handleShipCancel))
|
||||
mux.Handle("POST /api/panel/agents/{id}/fs/{op}", s.requireAdmin(s.handleFSOp))
|
||||
|
||||
// Machine status (admin).
|
||||
mux.Handle("GET /api/panel/agents/presence", s.requireAdmin(s.handlePresence))
|
||||
mux.Handle("GET /api/panel/agents/{id}/stat", s.requireAdmin(s.handleAgentStat))
|
||||
mux.Handle("POST /api/panel/agents/{id}/speed", s.requireAdmin(s.handleAgentSpeed))
|
||||
mux.Handle("GET /api/panel/agents/{id}/speed/progress", s.requireAdmin(s.handleSpeedProgress))
|
||||
mux.Handle("GET /api/panel/ping", s.requireAdmin(s.handlePanelPing))
|
||||
mux.Handle("POST /api/panel/agents/{id}/speed/relay", s.requireAdmin(s.handleSpeedRelayOpen))
|
||||
mux.Handle("POST /api/panel/agents/{id}/speed/down", s.requireAdmin(s.handleSpeedRelayFeed))
|
||||
mux.Handle("GET /api/panel/agents/{id}/speed/up", s.requireAdmin(s.handleSpeedRelayDrain))
|
||||
|
||||
// Panel port forwarding (admin-only).
|
||||
mux.Handle("POST /agents/{id}/forwards/add", s.requireAdmin(s.handleForwardAdd))
|
||||
mux.Handle("POST /forwards/{rid}/delete", s.requireAdmin(s.handleForwardDelete))
|
||||
@@ -155,10 +296,21 @@ func (s *Server) Handler() http.Handler {
|
||||
// Agent API (token/secret auth inside handlers).
|
||||
mux.HandleFunc("POST /api/agent/enroll", s.handleEnroll)
|
||||
mux.HandleFunc("GET /api/agent/poll", s.handlePoll)
|
||||
mux.HandleFunc("GET /api/agent/ws", s.handleAgentWS)
|
||||
mux.HandleFunc("GET /api/agent/config", s.handleAgentConfig)
|
||||
mux.HandleFunc("GET /api/agent/term/{sid}/in", s.handleTermIn)
|
||||
mux.HandleFunc("POST /api/agent/term/{sid}/out", s.handleTermOut)
|
||||
mux.HandleFunc("GET /api/agent/task/{id}", s.handleTaskFetch)
|
||||
mux.HandleFunc("POST /api/agent/task/{id}/result", s.handleTaskResult)
|
||||
mux.HandleFunc("GET /api/agent/speed/down", s.handleSpeedDown)
|
||||
mux.HandleFunc("POST /api/agent/speed/up", s.handleSpeedUp)
|
||||
mux.HandleFunc("GET /api/agent/speed/relay/down", s.handleSpeedRelayDown)
|
||||
mux.HandleFunc("POST /api/agent/speed/relay/up", s.handleSpeedRelayUp)
|
||||
mux.HandleFunc("GET /api/agent/fs/xfer/{xid}/{i}", s.handleAgentXferGet)
|
||||
mux.HandleFunc("POST /api/agent/fs/xfer/{xid}/{i}", s.handleAgentXferPost)
|
||||
mux.HandleFunc("POST /api/agent/fs/job/{jid}/progress", s.handleShipProgress)
|
||||
mux.HandleFunc("POST /api/agent/fs/relay/{rid}/{i}", s.handleRelayPost)
|
||||
mux.HandleFunc("GET /api/agent/fs/relay/{rid}/{i}", s.handleRelayGet)
|
||||
|
||||
return s.logRequests(mux)
|
||||
}
|
||||
@@ -175,6 +327,36 @@ func (s *Server) requireAdmin(h http.HandlerFunc) http.Handler {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
// A user who is not an admin uses only the machines given to them:
|
||||
// any other is as if it were not there.
|
||||
if !admin.IsAdmin() {
|
||||
p := r.URL.Path
|
||||
if strings.HasPrefix(p, "/agents/") || strings.HasPrefix(p, "/api/panel/agents/") || strings.HasPrefix(p, "/api/panel/term/") {
|
||||
id := r.PathValue("id")
|
||||
if id == "" {
|
||||
id = r.PathValue("agentID")
|
||||
}
|
||||
if id != "" && id != "presence" && !s.db.CanUseAgent(admin, id) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
h(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// adminsOnly lets only admins through: the settings, users, the firewall.
|
||||
func (s *Server) adminsOnly(h http.HandlerFunc) http.Handler {
|
||||
return s.requireAdmin(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.currentAdmin(r).IsAdmin() {
|
||||
if r.Method == http.MethodGet && !strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Error(w, "только для администраторов", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
h(w, r)
|
||||
})
|
||||
}
|
||||
@@ -200,17 +382,15 @@ func (s *Server) render(w http.ResponseWriter, name string, data map[string]any)
|
||||
}
|
||||
}
|
||||
|
||||
// portFromAddr extracts the numeric port from a "host:port" or ":port" address.
|
||||
func portFromAddr(addr string) int {
|
||||
if addr == "" {
|
||||
return 0
|
||||
}
|
||||
_, p, err := net.SplitHostPort(addr)
|
||||
// splitAddr splits a "host:port" or ":port" listen address. The port is 0 when
|
||||
// the address has none.
|
||||
func splitAddr(addr string) (host string, port int) {
|
||||
h, p, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return 0
|
||||
return "", 0
|
||||
}
|
||||
n, _ := strconv.Atoi(p)
|
||||
return n
|
||||
return h, n
|
||||
}
|
||||
|
||||
func clientIP(r *http.Request) string {
|
||||
@@ -230,3 +410,39 @@ func clientIP(r *http.Request) string {
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// assetHashes hashes every static file, for asset URLs and ETags.
|
||||
func assetHashes(fsys fs.FS) map[string]string {
|
||||
out := map[string]string{}
|
||||
_ = fs.WalkDir(fsys, ".", func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if data, err := fs.ReadFile(fsys, p); err == nil {
|
||||
sum := sha256.Sum256(data)
|
||||
out[p] = hex.EncodeToString(sum[:8])
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// staticHandler serves the embedded assets. Pages ask for them by URLs that
|
||||
// carry the content hash (?v=): such a file never changes and is kept for a
|
||||
// year, by the browser and by a proxy in front such as Cloudflare. Asked for
|
||||
// without it, a file is checked every time against its ETag.
|
||||
func staticHandler(fsys fs.FS, hashes map[string]string) http.Handler {
|
||||
files := http.FileServer(http.FS(fsys))
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h := hashes[strings.TrimPrefix(r.URL.Path, "/")]
|
||||
if h != "" {
|
||||
w.Header().Set("ETag", `"`+h+`"`)
|
||||
}
|
||||
if v := r.URL.Query().Get("v"); h != "" && v == h {
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
} else {
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
}
|
||||
files.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||
)
|
||||
|
||||
// Sending a file or folder to another machine. The sending agent packs it as
|
||||
// a tar stream; to another agent the stream goes in pieces through a relay
|
||||
// here, a few pieces held at most, and the receiving agent unpacks it as it
|
||||
// comes. To an SSH host the sending agent writes it there itself. The panel
|
||||
// follows a job: how far the sender is, and the result.
|
||||
|
||||
// relay carries one stream between two agents, in numbered pieces.
|
||||
type relay struct {
|
||||
id, from, to string
|
||||
|
||||
mu sync.Mutex
|
||||
changed chan struct{}
|
||||
chunks map[int][]byte
|
||||
low int // pieces below it were taken
|
||||
end int // the last piece's number, -1 until it came
|
||||
err string
|
||||
}
|
||||
|
||||
func (x *relay) notify() {
|
||||
close(x.changed)
|
||||
x.changed = make(chan struct{})
|
||||
}
|
||||
|
||||
// await waits until ready() holds and returns true with x.mu held.
|
||||
func (x *relay) await(ctx context.Context, d time.Duration, ready func() bool) bool {
|
||||
t := time.NewTimer(d)
|
||||
defer t.Stop()
|
||||
for {
|
||||
x.mu.Lock()
|
||||
if ready() {
|
||||
return true
|
||||
}
|
||||
ch := x.changed
|
||||
x.mu.Unlock()
|
||||
select {
|
||||
case <-ch:
|
||||
case <-t.C:
|
||||
return false
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (x *relay) fail(msg string) {
|
||||
x.mu.Lock()
|
||||
if x.err == "" {
|
||||
x.err = msg
|
||||
x.chunks = nil
|
||||
x.notify()
|
||||
}
|
||||
x.mu.Unlock()
|
||||
}
|
||||
|
||||
type shipJob struct {
|
||||
id string
|
||||
mu sync.Mutex
|
||||
changed chan struct{}
|
||||
ver int
|
||||
prog proto.ShipProgress
|
||||
done bool
|
||||
res proto.TaskResult
|
||||
cancel context.CancelFunc
|
||||
stopped bool
|
||||
}
|
||||
|
||||
func (j *shipJob) update(fn func()) {
|
||||
j.mu.Lock()
|
||||
fn()
|
||||
j.ver++
|
||||
close(j.changed)
|
||||
j.changed = make(chan struct{})
|
||||
j.mu.Unlock()
|
||||
}
|
||||
|
||||
type shipping struct {
|
||||
mu sync.Mutex
|
||||
relays map[string]*relay
|
||||
jobs map[string]*shipJob
|
||||
}
|
||||
|
||||
func (c *shipping) relay(id string) *relay {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.relays[id]
|
||||
}
|
||||
|
||||
func (c *shipping) job(id string) *shipJob {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.jobs[id]
|
||||
}
|
||||
|
||||
// shipReq is what the panel asks for: Src on this agent to Dir/Name on agent
|
||||
// To, or on the SSH host.
|
||||
type shipReq struct {
|
||||
Src string `json:"src"`
|
||||
Name string `json:"name"`
|
||||
Dir string `json:"dir"`
|
||||
Conflict string `json:"conflict"`
|
||||
Move bool `json:"move"`
|
||||
To string `json:"to"`
|
||||
SSH *proto.SSHTarget `json:"ssh"`
|
||||
}
|
||||
|
||||
// POST /api/panel/agents/{id}/fs/ship - start sending; the answer names the
|
||||
// job to follow.
|
||||
func (s *Server) handleFSShip(w http.ResponseWriter, r *http.Request) {
|
||||
from := r.PathValue("id")
|
||||
var q shipReq
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&q); err != nil {
|
||||
writeJSON(w, proto.TaskResult{Error: "bad request"})
|
||||
return
|
||||
}
|
||||
q.Name, q.Dir = strings.TrimSpace(q.Name), strings.TrimSpace(q.Dir)
|
||||
if q.Src == "" || q.Name == "" || strings.Contains(q.Name, "/") || q.Dir == "" {
|
||||
writeJSON(w, proto.TaskResult{Error: "не указано, что и куда"})
|
||||
return
|
||||
}
|
||||
if !s.fsAgent(w, from, true) {
|
||||
return
|
||||
}
|
||||
where := ""
|
||||
if q.SSH != nil {
|
||||
t := q.SSH
|
||||
t.Host, t.User = strings.TrimSpace(t.Host), strings.TrimSpace(t.User)
|
||||
if t.Port == 0 {
|
||||
t.Port = 22
|
||||
}
|
||||
if t.Host == "" || t.User == "" || t.Port < 1 || t.Port > 65535 {
|
||||
writeJSON(w, proto.TaskResult{Error: "нужны хост, порт и пользователь"})
|
||||
return
|
||||
}
|
||||
where = "ssh:" + t.User + "@" + t.Host + ":" + strconv.Itoa(t.Port)
|
||||
} else {
|
||||
if q.To == "" {
|
||||
writeJSON(w, proto.TaskResult{Error: "не выбрана машина"})
|
||||
return
|
||||
}
|
||||
if !s.db.CanUseAgent(s.currentAdmin(r), q.To) {
|
||||
writeJSON(w, proto.TaskResult{Error: "нет доступа к той машине"})
|
||||
return
|
||||
}
|
||||
if !s.fsAgent(w, q.To, true) {
|
||||
return
|
||||
}
|
||||
where = "agent:" + q.To
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), xferTimeout)
|
||||
j := &shipJob{id: randToken()[:24], changed: make(chan struct{}), cancel: cancel}
|
||||
s.ship.mu.Lock()
|
||||
if s.ship.jobs == nil {
|
||||
s.ship.jobs, s.ship.relays = map[string]*shipJob{}, map[string]*relay{}
|
||||
}
|
||||
s.ship.jobs[j.id] = j
|
||||
s.ship.mu.Unlock()
|
||||
if admin := s.currentAdmin(r); admin != nil {
|
||||
op := "copy"
|
||||
if q.Move {
|
||||
op = "move"
|
||||
}
|
||||
s.db.Audit(admin.ID, from, "fs.ship."+op+":"+q.Src+" -> "+where+":"+strings.TrimSuffix(q.Dir, "/")+"/"+q.Name)
|
||||
}
|
||||
go s.runShip(ctx, j, from, q)
|
||||
data, _ := json.Marshal(map[string]string{"job": j.id})
|
||||
writeJSON(w, proto.TaskResult{OK: true, Data: data})
|
||||
}
|
||||
|
||||
func (s *Server) runShip(ctx context.Context, j *shipJob, from string, q shipReq) {
|
||||
defer j.cancel()
|
||||
res := s.ship1(ctx, j, from, q)
|
||||
if !res.OK && j.stopped {
|
||||
res.Error = "отменено"
|
||||
}
|
||||
j.update(func() { j.done, j.res = true, res })
|
||||
// The result waits a while for the panel to come for it.
|
||||
time.AfterFunc(xferIdle, func() {
|
||||
s.ship.mu.Lock()
|
||||
delete(s.ship.jobs, j.id)
|
||||
s.ship.mu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
func oldAgent(res proto.TaskResult) proto.TaskResult {
|
||||
if unknownOp(res) {
|
||||
res.Error = "агент не умеет отправлять на другие машины - обновите его"
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func (s *Server) ship1(ctx context.Context, j *shipJob, from string, q shipReq) proto.TaskResult {
|
||||
args := proto.FSShipArgs{Src: q.Src, Name: q.Name, Dir: q.Dir, Conflict: q.Conflict, Job: j.id}
|
||||
if q.SSH != nil {
|
||||
args.SSH, args.Move = q.SSH, q.Move
|
||||
res, err := s.waitTask(ctx, from, proto.TaskFSShipOut, args, xferTimeout)
|
||||
if err != nil {
|
||||
return proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
return oldAgent(res)
|
||||
}
|
||||
|
||||
x := &relay{id: randToken()[:24], from: from, to: q.To, changed: make(chan struct{}), chunks: map[int][]byte{}, end: -1}
|
||||
s.ship.mu.Lock()
|
||||
s.ship.relays[x.id] = x
|
||||
s.ship.mu.Unlock()
|
||||
defer func() {
|
||||
s.ship.mu.Lock()
|
||||
delete(s.ship.relays, x.id)
|
||||
s.ship.mu.Unlock()
|
||||
}()
|
||||
go func() { <-ctx.Done(); x.fail("отменено") }()
|
||||
args.Relay, args.Chunk = x.id, xferChunk
|
||||
|
||||
var in proto.TaskResult
|
||||
inDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(inDone)
|
||||
res, err := s.waitTask(ctx, q.To, proto.TaskFSShipIn, args, xferTimeout)
|
||||
if err != nil {
|
||||
res = proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
in = oldAgent(res)
|
||||
if !in.OK {
|
||||
x.fail(in.Error)
|
||||
}
|
||||
}()
|
||||
out, err := s.waitTask(ctx, from, proto.TaskFSShipOut, args, xferTimeout)
|
||||
if err != nil {
|
||||
out = proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
out = oldAgent(out)
|
||||
if !out.OK {
|
||||
x.fail(out.Error)
|
||||
}
|
||||
<-inDone
|
||||
if !in.OK {
|
||||
return proto.TaskResult{Error: "на той машине: " + in.Error}
|
||||
}
|
||||
if !out.OK {
|
||||
return out
|
||||
}
|
||||
if q.Move {
|
||||
res, err := s.waitTask(ctx, from, proto.TaskFSDelTree, proto.FSPathArgs{Path: q.Src}, fsTaskTimeout)
|
||||
if err == nil && !res.OK {
|
||||
err = errText(res.Error)
|
||||
}
|
||||
if err != nil {
|
||||
return proto.TaskResult{Error: "отправлено, но здесь не удалилось: " + err.Error()}
|
||||
}
|
||||
}
|
||||
// The receiver's count: what it put down and what it kept as it was.
|
||||
return in
|
||||
}
|
||||
|
||||
type errText string
|
||||
|
||||
func (e errText) Error() string { return string(e) }
|
||||
|
||||
// GET /api/panel/fs/job/{jid}?v=N - the job's state once it differs from
|
||||
// version N, or after a while anyway.
|
||||
func (s *Server) handleShipJob(w http.ResponseWriter, r *http.Request) {
|
||||
j := s.ship.job(r.PathValue("jid"))
|
||||
if j == nil {
|
||||
writeJSON(w, map[string]any{"done": true, "result": proto.TaskResult{Error: "задание не найдено"}})
|
||||
return
|
||||
}
|
||||
seen, _ := strconv.Atoi(r.URL.Query().Get("v"))
|
||||
t := time.NewTimer(25 * time.Second)
|
||||
defer t.Stop()
|
||||
for {
|
||||
j.mu.Lock()
|
||||
if j.ver != seen || j.done {
|
||||
out := map[string]any{"v": j.ver, "progress": j.prog, "done": j.done}
|
||||
if j.done {
|
||||
out["result"] = j.res
|
||||
}
|
||||
j.mu.Unlock()
|
||||
writeJSON(w, out)
|
||||
return
|
||||
}
|
||||
ch := j.changed
|
||||
j.mu.Unlock()
|
||||
select {
|
||||
case <-ch:
|
||||
case <-t.C:
|
||||
seen = -1 // answer with what there is
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/panel/fs/job/{jid}/cancel
|
||||
func (s *Server) handleShipCancel(w http.ResponseWriter, r *http.Request) {
|
||||
if j := s.ship.job(r.PathValue("jid")); j != nil {
|
||||
j.update(func() { j.stopped = true })
|
||||
j.cancel()
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// POST /api/agent/fs/job/{jid}/progress - the sender's progress. 410: the
|
||||
// job is over or cancelled, stop.
|
||||
func (s *Server) handleShipProgress(w http.ResponseWriter, r *http.Request) {
|
||||
if s.authenticateAgent(r) == nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
j := s.ship.job(r.PathValue("jid"))
|
||||
var p proto.ShipProgress
|
||||
if j == nil || json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&p) != nil {
|
||||
http.Error(w, "gone", http.StatusGone)
|
||||
return
|
||||
}
|
||||
stop := false
|
||||
j.update(func() { j.prog, stop = p, j.stopped || j.done })
|
||||
if stop {
|
||||
http.Error(w, "gone", http.StatusGone)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// POST /api/agent/fs/relay/{rid}/{i} - piece i of the stream from the
|
||||
// sender, X-Last on the last. 503: no room yet, again; 410: it is over.
|
||||
func (s *Server) handleRelayPost(w http.ResponseWriter, r *http.Request) {
|
||||
a := s.authenticateAgent(r)
|
||||
if a == nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
x := s.ship.relay(r.PathValue("rid"))
|
||||
i, err := strconv.Atoi(r.PathValue("i"))
|
||||
if x == nil || x.from != a.ID || err != nil || i < 0 {
|
||||
http.Error(w, "передача не найдена", http.StatusGone)
|
||||
return
|
||||
}
|
||||
data, err := readPiece(w, r)
|
||||
if err != nil {
|
||||
http.Error(w, "кусок не дошёл", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !x.await(r.Context(), xferWait, func() bool { return x.err != "" || i < x.low+xferBuffer }) {
|
||||
http.Error(w, "нет места, повторите", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
defer x.mu.Unlock()
|
||||
if x.err != "" {
|
||||
http.Error(w, x.err, http.StatusGone)
|
||||
return
|
||||
}
|
||||
if i >= x.low && x.chunks[i] == nil {
|
||||
x.chunks[i] = data
|
||||
if r.Header.Get("X-Last") != "" {
|
||||
x.end = i
|
||||
}
|
||||
x.notify()
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// GET /api/agent/fs/relay/{rid}/{i} - piece i for the receiver, X-Last on
|
||||
// the last. Asking for i says every piece below it is written, so those are
|
||||
// let go. 503: not here yet, ask again; 410: it is over.
|
||||
func (s *Server) handleRelayGet(w http.ResponseWriter, r *http.Request) {
|
||||
a := s.authenticateAgent(r)
|
||||
if a == nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
x := s.ship.relay(r.PathValue("rid"))
|
||||
i, err := strconv.Atoi(r.PathValue("i"))
|
||||
if x == nil || x.to != a.ID || err != nil || i < 0 {
|
||||
http.Error(w, "передача не найдена", http.StatusGone)
|
||||
return
|
||||
}
|
||||
x.mu.Lock()
|
||||
if i > x.low {
|
||||
for k := x.low; k < i; k++ {
|
||||
delete(x.chunks, k)
|
||||
}
|
||||
x.low = i
|
||||
x.notify()
|
||||
}
|
||||
x.mu.Unlock()
|
||||
if !x.await(r.Context(), xferWait, func() bool { return x.err != "" || x.chunks[i] != nil }) {
|
||||
http.Error(w, "кусок ещё не пришёл", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if x.err != "" {
|
||||
msg := x.err
|
||||
x.mu.Unlock()
|
||||
http.Error(w, msg, http.StatusGone)
|
||||
return
|
||||
}
|
||||
data, last := x.chunks[i], i == x.end
|
||||
x.mu.Unlock()
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
|
||||
if last {
|
||||
w.Header().Set("X-Last", "1")
|
||||
}
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
@@ -39,6 +39,7 @@ func readLine(r io.Reader, max int) (string, error) {
|
||||
type tunnelHub struct {
|
||||
db *DB
|
||||
mu sync.Mutex
|
||||
ln net.Listener
|
||||
agents map[string]*agentTunnel
|
||||
}
|
||||
|
||||
@@ -57,12 +58,30 @@ type agentTunnel struct {
|
||||
streams map[uint32]net.Conn
|
||||
}
|
||||
|
||||
// listen runs the 8446 TCP tunnel acceptor until the listener closes.
|
||||
// listen runs the TCP tunnel acceptor until the listener closes. Listening
|
||||
// again moves the tunnel: the previous listener is closed and its agents are
|
||||
// dropped, so they redial the new address.
|
||||
func (h *tunnelHub) listen(addr string) error {
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h.mu.Lock()
|
||||
old := h.ln
|
||||
h.ln = ln
|
||||
var stale []*agentTunnel
|
||||
if old != nil {
|
||||
for _, at := range h.agents {
|
||||
stale = append(stale, at)
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
if old != nil {
|
||||
old.Close()
|
||||
}
|
||||
for _, at := range stale {
|
||||
at.conn.Close()
|
||||
}
|
||||
log.Printf("tunnel: listening on %s (direct TCP forwarding)", addr)
|
||||
go func() {
|
||||
for {
|
||||
|
||||
+24
-12
@@ -11,18 +11,19 @@ import (
|
||||
"github.com/x-grk/longpolling-remote-access/internal/tunnel"
|
||||
)
|
||||
|
||||
// udpRelay carries UDP forwards over the direct 8446 UDP channel. The agent
|
||||
// udpRelay carries UDP forwards over the direct UDP channel. The agent
|
||||
// registers its UDP return address; end-user datagrams arriving on a rule's
|
||||
// host port are relayed to the agent and replies are sent back.
|
||||
//
|
||||
// Datagram framing on the 8446 control socket:
|
||||
// Datagram framing on the tunnel control socket:
|
||||
//
|
||||
// registration (agent->server): "R <agentID> <sig>"
|
||||
// data (both): 'D' | sid(4) | port(2) | payload
|
||||
type udpRelay struct {
|
||||
db *DB
|
||||
mu sync.Mutex
|
||||
ctrl *net.UDPConn
|
||||
db *DB
|
||||
mu sync.Mutex
|
||||
ctrl *net.UDPConn
|
||||
gcOnce sync.Once
|
||||
|
||||
agentAddr map[string]*net.UDPAddr // agentID -> last registered addr
|
||||
sessions map[uint32]*udpSession
|
||||
@@ -48,7 +49,8 @@ func newUDPRelay(db *DB) *udpRelay {
|
||||
}
|
||||
}
|
||||
|
||||
// listen opens the shared 8446 UDP control socket.
|
||||
// listen opens the shared UDP control socket. Listening again moves it: the
|
||||
// previous socket is closed and agents register anew on the new address.
|
||||
func (u *udpRelay) listen(addr string) error {
|
||||
ua, err := net.ResolveUDPAddr("udp", addr)
|
||||
if err != nil {
|
||||
@@ -58,17 +60,26 @@ func (u *udpRelay) listen(addr string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u.mu.Lock()
|
||||
old := u.ctrl
|
||||
u.ctrl = c
|
||||
if old != nil {
|
||||
u.agentAddr = map[string]*net.UDPAddr{}
|
||||
}
|
||||
u.mu.Unlock()
|
||||
if old != nil {
|
||||
old.Close()
|
||||
}
|
||||
log.Printf("tunnel: listening on %s/udp (direct UDP forwarding)", addr)
|
||||
go u.ctrlLoop()
|
||||
go u.gc()
|
||||
go u.ctrlLoop(c)
|
||||
u.gcOnce.Do(func() { go u.gc() })
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *udpRelay) ctrlLoop() {
|
||||
func (u *udpRelay) ctrlLoop(ctrl *net.UDPConn) {
|
||||
buf := make([]byte, 65535)
|
||||
for {
|
||||
n, src, err := u.ctrl.ReadFromUDP(buf)
|
||||
n, src, err := ctrl.ReadFromUDP(buf)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -171,6 +182,7 @@ func (u *udpRelay) rulePortLoop(rule ForwardRule, c *net.UDPConn) {
|
||||
}
|
||||
u.mu.Lock()
|
||||
aAddr := u.agentAddr[rule.AgentID]
|
||||
ctrl := u.ctrl
|
||||
// Find existing session for this (rule, user).
|
||||
var sess *udpSession
|
||||
for _, s := range u.sessions {
|
||||
@@ -190,7 +202,7 @@ func (u *udpRelay) rulePortLoop(rule ForwardRule, c *net.UDPConn) {
|
||||
sess.last = time.Now()
|
||||
u.mu.Unlock()
|
||||
|
||||
if aAddr == nil {
|
||||
if aAddr == nil || ctrl == nil {
|
||||
continue // agent has not registered its UDP endpoint yet
|
||||
}
|
||||
// 'D' | sid(4) | clientPort(2) | payload
|
||||
@@ -199,7 +211,7 @@ func (u *udpRelay) rulePortLoop(rule ForwardRule, c *net.UDPConn) {
|
||||
binary.BigEndian.PutUint32(out[1:5], sess.id)
|
||||
binary.BigEndian.PutUint16(out[5:7], uint16(rule.ClientPort))
|
||||
copy(out[7:], buf[:n])
|
||||
_, _ = u.ctrl.WriteToUDP(out, aAddr)
|
||||
_, _ = ctrl.WriteToUDP(out, aAddr)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+154
-36
@@ -3,8 +3,11 @@ package server
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// ufwManager integrates with the host firewall (ufw) when it is installed and
|
||||
@@ -14,31 +17,53 @@ import (
|
||||
// ufw requires root, so this only works when the server process can run ufw
|
||||
// (e.g. running as root). When it cannot, integration is simply inactive.
|
||||
type ufwManager struct {
|
||||
db *DB
|
||||
tunnelPort int
|
||||
db *DB
|
||||
// tunnelPort returns the current direct-tunnel port (0 = tunnel disabled).
|
||||
tunnelPort func() int
|
||||
|
||||
mu sync.Mutex
|
||||
lastErr string // last failed ufw command, shown in Settings
|
||||
}
|
||||
|
||||
func newUfwManager(db *DB, tunnelPort int) *ufwManager {
|
||||
func newUfwManager(db *DB, tunnelPort func() int) *ufwManager {
|
||||
return &ufwManager{db: db, tunnelPort: tunnelPort}
|
||||
}
|
||||
|
||||
// ufwRun runs ufw with a fixed C locale: its output is translated, and we
|
||||
// match on the English text.
|
||||
func ufwRun(args ...string) (string, error) {
|
||||
cmd := exec.Command("ufw", args...)
|
||||
cmd.Env = append(os.Environ(), "LC_ALL=C", "LANG=C", "LANGUAGE=C")
|
||||
out, err := cmd.CombinedOutput()
|
||||
return strings.TrimSpace(string(out)), err
|
||||
}
|
||||
|
||||
// status reports whether ufw is installed, active, and runnable by this process.
|
||||
func (u *ufwManager) status() (installed, active, runnable bool) {
|
||||
path, err := exec.LookPath("ufw")
|
||||
if err != nil {
|
||||
if _, err := exec.LookPath("ufw"); err != nil {
|
||||
return false, false, false
|
||||
}
|
||||
installed = true
|
||||
out, err := exec.Command(path, "status").CombinedOutput()
|
||||
out, err := ufwRun("status")
|
||||
if err != nil {
|
||||
// Typically a permission error (not root) -> not runnable.
|
||||
return true, false, false
|
||||
}
|
||||
runnable = true
|
||||
active = strings.Contains(string(out), "Status: active")
|
||||
active = strings.Contains(out, "Status: active")
|
||||
return installed, active, runnable
|
||||
}
|
||||
|
||||
// enabled reports whether the integration is switched on and ufw is there to
|
||||
// drive. Unlike available it runs nothing, so pages can ask it freely.
|
||||
func (u *ufwManager) enabled() bool {
|
||||
if !u.db.UfwEnabled() {
|
||||
return false
|
||||
}
|
||||
_, err := exec.LookPath("ufw")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// available reports whether we can and should drive ufw now.
|
||||
func (u *ufwManager) available() bool {
|
||||
if !u.db.UfwEnabled() {
|
||||
@@ -48,30 +73,70 @@ func (u *ufwManager) available() bool {
|
||||
return installed && active && runnable
|
||||
}
|
||||
|
||||
// allow opens port/proto in ufw (if integration is available) and records it,
|
||||
// bound to the given forward rule id (0 = shared/manual).
|
||||
func (u *ufwManager) allow(port int, proto string, ruleID int64) {
|
||||
if !u.available() {
|
||||
return
|
||||
}
|
||||
func (u *ufwManager) setErr(msg string) {
|
||||
u.mu.Lock()
|
||||
u.lastErr = msg
|
||||
u.mu.Unlock()
|
||||
}
|
||||
|
||||
// LastError returns the last failed ufw command ("" when the last one worked).
|
||||
func (u *ufwManager) LastError() string {
|
||||
u.mu.Lock()
|
||||
defer u.mu.Unlock()
|
||||
return u.lastErr
|
||||
}
|
||||
|
||||
// allow opens port/proto in ufw and records it, bound to the given forward
|
||||
// rule id (0 = shared, ufwManual = added in Settings). It reports whether the
|
||||
// panel now tracks the rule. The caller has checked available().
|
||||
func (u *ufwManager) allow(port int, proto string, ruleID int64) bool {
|
||||
if u.db.HasUfwRule(port, proto) {
|
||||
return
|
||||
return true
|
||||
}
|
||||
spec := fmt.Sprintf("%d/%s", port, proto)
|
||||
if out, err := exec.Command("ufw", "allow", spec).CombinedOutput(); err != nil {
|
||||
log.Printf("ufw allow %s: %v: %s", spec, err, strings.TrimSpace(string(out)))
|
||||
return
|
||||
out, err := ufwRun("allow", spec)
|
||||
if err != nil {
|
||||
log.Printf("ufw allow %s: %v: %s", spec, err, out)
|
||||
u.setErr(fmt.Sprintf("ufw allow %s: %v: %s", spec, err, out))
|
||||
return false
|
||||
}
|
||||
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 false
|
||||
}
|
||||
_ = u.db.AddUfwRule(port, proto, ruleID)
|
||||
log.Printf("ufw: allowed %s", spec)
|
||||
return true
|
||||
}
|
||||
|
||||
// allowManual opens a port the admin asked for in Settings. A port the panel
|
||||
// already opened for a forward or the tunnel becomes the admin's, so it stays
|
||||
// when they no longer need it. It returns the error key for the settings page
|
||||
// ("" on success).
|
||||
func (u *ufwManager) allowManual(port int, proto string) string {
|
||||
if !u.available() {
|
||||
return "ufw_off"
|
||||
}
|
||||
if !u.allow(port, proto, ufwManual) {
|
||||
if u.LastError() != "" {
|
||||
return "ufw_failed"
|
||||
}
|
||||
return "ufw_exists"
|
||||
}
|
||||
_ = u.db.SetUfwRuleManual(port, proto)
|
||||
return ""
|
||||
}
|
||||
|
||||
// revokeRule deletes one tracked ufw rule from ufw and the tracking table.
|
||||
func (u *ufwManager) revokeRule(r UfwRule) {
|
||||
spec := fmt.Sprintf("%d/%s", r.Port, r.Proto)
|
||||
if out, err := exec.Command("ufw", "delete", "allow", spec).CombinedOutput(); err != nil {
|
||||
log.Printf("ufw delete allow %s: %v: %s", spec, err, strings.TrimSpace(string(out)))
|
||||
if out, err := ufwRun("delete", "allow", spec); err != nil {
|
||||
log.Printf("ufw delete allow %s: %v: %s", spec, err, out)
|
||||
u.setErr(fmt.Sprintf("ufw delete allow %s: %v: %s", spec, err, out))
|
||||
} else {
|
||||
u.setErr("")
|
||||
log.Printf("ufw: revoked %s", spec)
|
||||
}
|
||||
_ = u.db.DeleteUfwRule(r.ID)
|
||||
@@ -86,8 +151,8 @@ func (u *ufwManager) revoke(id int64) {
|
||||
u.revokeRule(*r)
|
||||
}
|
||||
|
||||
// revokeForRule removes the ufw rules bound to a specific forward rule. The
|
||||
// shared tunnel port (rule_id 0) is left alone, since other forwards use it.
|
||||
// revokeForRule removes the ufw rules bound to a specific forward rule, and
|
||||
// the shared tunnel port once no forward needs it any more.
|
||||
func (u *ufwManager) revokeForRule(ruleID int64) {
|
||||
rules, err := u.db.ListUfwRulesByRule(ruleID)
|
||||
if err != nil {
|
||||
@@ -96,24 +161,77 @@ func (u *ufwManager) revokeForRule(ruleID int64) {
|
||||
for _, r := range rules {
|
||||
u.revokeRule(r)
|
||||
}
|
||||
u.pruneShared()
|
||||
}
|
||||
|
||||
// pruneShared revokes shared (rule_id 0) ufw rules that nothing uses: anything
|
||||
// but the current tunnel port, and the tunnel port itself for a protocol that
|
||||
// has no direct forward left.
|
||||
func (u *ufwManager) pruneShared() {
|
||||
shared, err := u.db.ListUfwRulesByRule(0)
|
||||
if err != nil || len(shared) == 0 {
|
||||
return
|
||||
}
|
||||
forwards, err := u.db.ListForwardRules()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
used := map[string]bool{}
|
||||
for _, f := range forwards {
|
||||
if !f.NoUfw {
|
||||
used[f.Proto] = true
|
||||
}
|
||||
}
|
||||
port := u.tunnelPort()
|
||||
for _, r := range shared {
|
||||
if r.Port != port || !used[r.Proto] {
|
||||
u.revokeRule(r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// allowForward opens the host ports a forward rule needs: its host port (bound
|
||||
// to the rule so it is revoked with it) plus the shared 8446 tunnel port for
|
||||
// direct tcp/udp modes (bound to 0 — shared across rules).
|
||||
// to the rule so it is revoked with it) plus the shared tunnel port for direct
|
||||
// tcp/udp modes (bound to 0 - shared across rules). A rule made "without ufw"
|
||||
// opens nothing, so nothing is revoked with it either.
|
||||
func (u *ufwManager) allowForward(rule ForwardRule) {
|
||||
switch rule.Proto {
|
||||
case "udp":
|
||||
u.allow(rule.HostPort, "udp", rule.ID)
|
||||
if u.tunnelPort > 0 {
|
||||
u.allow(u.tunnelPort, "udp", 0)
|
||||
}
|
||||
case "tcp":
|
||||
u.allow(rule.HostPort, "tcp", rule.ID)
|
||||
if u.tunnelPort > 0 {
|
||||
u.allow(u.tunnelPort, "tcp", 0)
|
||||
}
|
||||
default: // http long-poll listener is TCP
|
||||
u.allow(rule.HostPort, "tcp", rule.ID)
|
||||
if !u.available() {
|
||||
return
|
||||
}
|
||||
u.allowForwardPorts(rule)
|
||||
}
|
||||
|
||||
func (u *ufwManager) allowForwardPorts(rule ForwardRule) {
|
||||
if rule.NoUfw {
|
||||
return
|
||||
}
|
||||
// "http" (long-poll) and "tcp" forwards both listen on TCP on the host.
|
||||
hostProto, direct := "tcp", rule.Proto == "tcp"
|
||||
if rule.Proto == "udp" {
|
||||
hostProto, direct = "udp", true
|
||||
}
|
||||
// A listener bound to loopback is not reachable from outside anyway.
|
||||
if ip := net.ParseIP(rule.ListenAddr); ip == nil || !ip.IsLoopback() {
|
||||
u.allow(rule.HostPort, hostProto, rule.ID)
|
||||
}
|
||||
if port := u.tunnelPort(); direct && port > 0 {
|
||||
u.allow(port, hostProto, 0)
|
||||
}
|
||||
}
|
||||
|
||||
// sync brings ufw in line with the saved forward rules: it opens what is
|
||||
// missing (rules made while ufw or the integration was off, or a tunnel port
|
||||
// that has since changed) and drops shared rules nothing uses.
|
||||
func (u *ufwManager) sync() {
|
||||
if !u.available() {
|
||||
return
|
||||
}
|
||||
forwards, err := u.db.ListForwardRules()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, f := range forwards {
|
||||
u.allowForwardPorts(f)
|
||||
}
|
||||
u.pruneShared()
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// GET /api/panel/users/{id}/agents - every machine, and which of them the
|
||||
// user may use, for the editor of a user's machines.
|
||||
func (s *Server) handleUserAgents(w http.ResponseWriter, r *http.Request) {
|
||||
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
u, _ := s.db.AdminByID(id)
|
||||
if u == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
agents, _ := s.db.ListAgents()
|
||||
mine := s.db.UserAgents(id)
|
||||
type item struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Hostname string `json:"hostname"`
|
||||
Online bool `json:"online"`
|
||||
On bool `json:"on"`
|
||||
}
|
||||
out := make([]item, 0, len(agents))
|
||||
for _, a := range agents {
|
||||
out = append(out, item{a.ID, a.Name, a.Hostname, s.online(&a), mine[a.ID]})
|
||||
}
|
||||
writeJSON(w, map[string]any{"username": u.Username, "agents": out})
|
||||
}
|
||||
|
||||
// POST /api/panel/users/{id} - change a user: the role, the machines, the
|
||||
// password sign-in, and whether they may switch it themselves. Fields left
|
||||
// out stay as they are.
|
||||
func (s *Server) handleUserEdit(w http.ResponseWriter, r *http.Request) {
|
||||
me := s.currentAdmin(r)
|
||||
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
u, _ := s.db.AdminByID(id)
|
||||
if u == nil {
|
||||
writeJSON(w, map[string]any{"ok": false, "error": "нет такого пользователя"})
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
Role *string `json:"role"`
|
||||
Agents *[]string `json:"agents"`
|
||||
PasswordOff *bool `json:"password_off"`
|
||||
PwLocked *bool `json:"pw_locked"`
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&in); err != nil {
|
||||
writeJSON(w, map[string]any{"ok": false, "error": "плохой запрос"})
|
||||
return
|
||||
}
|
||||
fail := func(msg string) { writeJSON(w, map[string]any{"ok": false, "error": msg}) }
|
||||
if in.Role != nil {
|
||||
role := *in.Role
|
||||
if role != RoleAdmin && role != RoleUser {
|
||||
fail("нет такой роли")
|
||||
return
|
||||
}
|
||||
if role == RoleUser && u.IsAdmin() && s.db.CountRole(RoleAdmin) <= 1 {
|
||||
fail("это последний администратор")
|
||||
return
|
||||
}
|
||||
_ = s.db.SetUserRole(id, role)
|
||||
s.db.Audit(me.ID, "", "user_role:"+u.Username+":"+role)
|
||||
}
|
||||
if in.Agents != nil {
|
||||
_ = s.db.SetUserAgents(id, *in.Agents)
|
||||
s.db.Audit(me.ID, "", "user_agents:"+u.Username+":"+strconv.Itoa(len(*in.Agents)))
|
||||
}
|
||||
if in.PasswordOff != nil {
|
||||
if *in.PasswordOff && s.db.CountAdminKeys(id) == 0 {
|
||||
fail("у пользователя нет ключей доступа - без пароля он не войдёт")
|
||||
return
|
||||
}
|
||||
_ = s.db.SetPasswordOff(id, *in.PasswordOff)
|
||||
action := "password_on"
|
||||
if *in.PasswordOff {
|
||||
action = "password_off"
|
||||
}
|
||||
s.db.Audit(me.ID, "", action+":"+u.Username)
|
||||
}
|
||||
if in.PwLocked != nil {
|
||||
_ = s.db.SetPwLocked(id, *in.PwLocked)
|
||||
}
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
}
|
||||
@@ -7,6 +7,10 @@
|
||||
set -eu
|
||||
|
||||
HOST="${HOST:-{{.Host}}}"
|
||||
# The server's other address (the direct one, not behind a proxy like
|
||||
# Cloudflare, or the main one when this script came from the direct one): the
|
||||
# agent turns to it when HOST is out of reach.
|
||||
FALLBACK_HOST="${FALLBACK_HOST:-{{.Fallback}}}"
|
||||
{{if .Secret}}ENROLL_SECRET="${ENROLL_SECRET:-{{.Secret}}}"
|
||||
{{end}}
|
||||
BIN_PATH="/usr/local/bin/lpagent"
|
||||
@@ -108,9 +112,9 @@ fi
|
||||
# --- downloader -------------------------------------------------------------
|
||||
download() { # download URL DEST
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl -fSL -o "$2" "$1"
|
||||
curl -fSL --connect-timeout 15 -o "$2" "$1"
|
||||
elif command -v wget >/dev/null 2>&1; then
|
||||
wget -qO "$2" "$1"
|
||||
wget -q -T 30 -O "$2" "$1"
|
||||
else
|
||||
red "Нужен curl или wget."; exit 1
|
||||
fi
|
||||
@@ -118,7 +122,14 @@ download() { # download URL DEST
|
||||
|
||||
bold "Скачивание агента ($ARCH) с $HOST…"
|
||||
TMP="$(mktemp)"
|
||||
download "$HOST/download/agent-linux-$ARCH" "$TMP"
|
||||
if ! download "$HOST/download/agent-linux-$ARCH" "$TMP"; then
|
||||
if [ -n "$FALLBACK_HOST" ] && [ "$FALLBACK_HOST" != "$HOST" ]; then
|
||||
bold "$HOST недоступен, скачивание с $FALLBACK_HOST…"
|
||||
download "$FALLBACK_HOST/download/agent-linux-$ARCH" "$TMP"
|
||||
else
|
||||
red "Не удалось скачать агента."; exit 1
|
||||
fi
|
||||
fi
|
||||
install -m 0755 "$TMP" "$BIN_PATH"
|
||||
rm -f "$TMP"
|
||||
green "Бинарник установлен: $BIN_PATH"
|
||||
@@ -129,6 +140,7 @@ chmod 700 "$CONF_DIR"
|
||||
umask 077
|
||||
cat > "$CONF_FILE" <<EOF
|
||||
HOST=$HOST
|
||||
FALLBACK_HOST=$FALLBACK_HOST
|
||||
ENROLL_SECRET=${ENROLL_SECRET:-}
|
||||
AGENT_NAME=$AGENT_NAME
|
||||
STATE_PATH=$STATE_FILE
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
// The account button in the top bar: a dialog with who is signed in, their
|
||||
// passkeys (add one, remove one), the password switch and signing out. The
|
||||
// password can be turned off once there is a passkey to sign in with.
|
||||
(function () {
|
||||
'use strict';
|
||||
var btn = document.getElementById('acct');
|
||||
if (!btn) return;
|
||||
|
||||
var b64u = {
|
||||
enc: function (buf) {
|
||||
var s = '', b = new Uint8Array(buf);
|
||||
for (var i = 0; i < b.length; i++) s += String.fromCharCode(b[i]);
|
||||
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
},
|
||||
dec: function (str) {
|
||||
var s = atob(str.replace(/-/g, '+').replace(/_/g, '/') + '==='.slice((str.length + 3) % 4));
|
||||
var b = new Uint8Array(s.length);
|
||||
for (var i = 0; i < s.length; i++) b[i] = s.charCodeAt(i);
|
||||
return b.buffer;
|
||||
}
|
||||
};
|
||||
var esc = function (s) { return String(s).replace(/[&<>"]/g, function (c) { return {'&': '&', '<': '<', '>': '>', '"': '"'}[c]; }); };
|
||||
var when = function (t) { return t ? new Date(t * 1000).toLocaleString() : 'ещё не использовался'; };
|
||||
var csrf = (document.cookie.match(/(?:^|; )lpra_csrf=([^;]*)/) || [])[1] || '';
|
||||
var X = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>';
|
||||
var KEY = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="7.5" cy="15.5" r="4.5"/><path d="m21 2-9.6 9.6M15.5 7.5l3 3L22 7l-3-3"/></svg>';
|
||||
|
||||
var dlg = document.createElement('dialog');
|
||||
dlg.id = 'acctdlg';
|
||||
dlg.tabIndex = -1;
|
||||
dlg.innerHTML =
|
||||
'<div class="dlg-h"><strong style="flex:1">Аккаунт</strong>' +
|
||||
'<button type="button" class="btn ghost small icon close x" data-close title="Закрыть" aria-label="Закрыть">' + X + '</button></div>' +
|
||||
'<div class="dlg-b acct">' +
|
||||
' <div class="acct-who"><span class="acct-ava"></span><div><div class="acct-name"></div><div class="muted acct-mode"></div></div></div>' +
|
||||
' <h3>Ключи доступа</h3>' +
|
||||
' <p class="muted acct-hint">Вход отпечатком, лицом, PIN-кодом устройства или аппаратным ключом. Ключей может быть несколько - например, телефон и ноутбук.</p>' +
|
||||
' <ul class="acct-keys"></ul>' +
|
||||
' <div class="acct-add"><input type="text" class="acct-keyname" maxlength="60" placeholder="название, например «телефон»">' +
|
||||
' <button type="button" class="acct-addbtn">Добавить ключ</button></div>' +
|
||||
' <p class="acct-msg"></p>' +
|
||||
' <label class="acct-pw"><span><b>Вход по паролю</b><small class="muted acct-pwnote"></small></span>' +
|
||||
' <input type="checkbox" class="sw" role="switch"></label>' +
|
||||
' <details class="acct-chpw"><summary>Сменить пароль</summary>' +
|
||||
' <form class="acct-pwform" autocomplete="on">' +
|
||||
' <input type="text" name="username" autocomplete="username" hidden>' +
|
||||
' <input type="password" name="current" placeholder="текущий пароль" autocomplete="current-password" required>' +
|
||||
' <input type="password" name="new" placeholder="новый пароль, от 8 символов" autocomplete="new-password" minlength="8" required>' +
|
||||
' <input type="password" name="again" placeholder="новый ещё раз" autocomplete="new-password" required>' +
|
||||
' <button type="submit">Сменить</button></form></details>' +
|
||||
' <details class="acct-pin"><summary>PIN-код <small class="acct-pinstate"></small></summary>' +
|
||||
' <form class="acct-pinform">' +
|
||||
' <input type="password" name="current" inputmode="numeric" maxlength="4" pattern="[0-9]{4}" placeholder="текущий PIN" autocomplete="off">' +
|
||||
' <input type="password" name="pin" inputmode="numeric" maxlength="4" pattern="[0-9]{4}" placeholder="новый PIN, 4 цифры" autocomplete="off">' +
|
||||
' <input type="password" name="again" inputmode="numeric" maxlength="4" pattern="[0-9]{4}" placeholder="новый ещё раз" autocomplete="off">' +
|
||||
' <div class="row"><button type="button" class="btn danger acct-pindel">Убрать PIN</button><button type="submit" class="acct-pinsave">Сохранить</button></div>' +
|
||||
' </form>' +
|
||||
' <label class="acct-pingrace"><span>На этом устройстве не спрашивать после ввода</span><select>' +
|
||||
' <option value="0">каждый раз</option><option value="5">5 минут</option><option value="15">15 минут</option>' +
|
||||
' <option value="30">30 минут</option><option value="60">60 минут</option></select></label>' +
|
||||
' <p class="acct-pinmsg" aria-live="polite"></p><p class="muted" style="font-size:12px;margin:8px 0 0">Спрашивается в каждой новой вкладке; при перезагрузке той же вкладки - нет.</p></details>' +
|
||||
' <form method="post" action="/logout" class="acct-out"><input type="hidden" name="csrf"><button type="submit" class="btn ghost">Выйти</button></form>' +
|
||||
'</div>';
|
||||
document.body.appendChild(dlg);
|
||||
dlg.querySelector('.acct-out [name=csrf]').value = decodeURIComponent(csrf);
|
||||
var $ = function (sel) { return dlg.querySelector(sel); };
|
||||
$('[data-close]').onclick = function () { dlg.close(); };
|
||||
var supported = !!(window.PublicKeyCredential && navigator.credentials && window.isSecureContext);
|
||||
|
||||
function msg(text, bad) {
|
||||
$('.acct-msg').textContent = text || '';
|
||||
$('.acct-msg').className = 'acct-msg' + (bad ? ' bad' : '');
|
||||
}
|
||||
function load() {
|
||||
return fetch('/api/panel/account', {cache: 'no-store'}).then(function (r) { return r.json(); }).then(function (d) {
|
||||
$('.acct-name').textContent = d.username;
|
||||
dlg.querySelector('.acct-pwform [name=username]').value = d.username;
|
||||
const pf = dlg.querySelector('.acct-pinform');
|
||||
dlg.querySelector('.acct-pinstate').textContent = d.pin ? '· установлен' : '· не задан';
|
||||
pf.current.hidden = !d.pin;
|
||||
pf.querySelector('.acct-pindel').hidden = !d.pin;
|
||||
pf.querySelector('.acct-pinsave').textContent = d.pin ? 'Сменить' : 'Установить';
|
||||
$('.acct-pingrace').hidden = !d.pin;
|
||||
try { $('.acct-pingrace select').value = localStorage.getItem('lpra.pin.grace') || '0'; } catch (e) { /* default */ }
|
||||
$('.acct-ava').textContent = (d.username || '?').slice(0, 1).toUpperCase();
|
||||
$('.acct-mode').textContent = d.password_off ? 'вход только по ключу доступа' : d.keys.length ? 'вход по паролю или ключу' : 'вход по паролю';
|
||||
const sw = $('.acct-pw .sw');
|
||||
sw.checked = !d.password_off;
|
||||
sw.disabled = !d.keys.length || d.pw_locked;
|
||||
$('.acct-pwnote').textContent = d.pw_locked ? 'закреплено администратором' + (d.password_off ? ': только по ключу' : '')
|
||||
: !d.keys.length ? 'выключить можно, когда есть хотя бы один ключ'
|
||||
: d.password_off ? 'выключен: войти можно только ключом' : 'можно выключить и входить только ключом';
|
||||
var ul = $('.acct-keys');
|
||||
ul.innerHTML = d.keys.length ? '' : '<li class="muted">Ключей пока нет.</li>';
|
||||
d.keys.forEach(function (k) {
|
||||
var li = document.createElement('li');
|
||||
li.innerHTML = '<span class="acct-kicon">' + KEY + '</span><div class="acct-kinfo"><div>' + esc(k.name) + '</div>' +
|
||||
'<div class="muted">добавлен ' + esc(when(k.created)) + ' · вход: ' + esc(when(k.last_used)) + '</div></div>' +
|
||||
'<button type="button" class="btn danger small" title="Удалить ключ">Удалить</button>';
|
||||
li.querySelector('button').onclick = function () {
|
||||
var last = d.keys.length === 1;
|
||||
if (!confirm('Удалить ключ «' + k.name + '»?' + (last && d.password_off ? ' Это последний ключ: вход по паролю снова включится.' : ''))) return;
|
||||
fetch('/api/panel/account/keys/' + k.id + '/delete', {method: 'POST'}).then(function (r) { return r.json(); })
|
||||
.then(function (r) { if (!r.ok) msg(r.error, true); else { msg('ключ удалён'); load(); } });
|
||||
};
|
||||
ul.appendChild(li);
|
||||
});
|
||||
});
|
||||
}
|
||||
$('.acct-addbtn').onclick = function () {
|
||||
if (!supported) { msg('этот браузер не умеет ключи доступа, или страница открыта не по https', true); return; }
|
||||
var add = $('.acct-addbtn');
|
||||
add.disabled = true;
|
||||
msg('подтвердите на устройстве…');
|
||||
fetch('/api/panel/account/keys/begin', {method: 'POST'}).then(function (r) { return r.json(); }).then(function (r) {
|
||||
var o = r.options;
|
||||
o.challenge = b64u.dec(o.challenge);
|
||||
o.user.id = b64u.dec(o.user.id);
|
||||
o.excludeCredentials.forEach(function (c) { c.id = b64u.dec(c.id); });
|
||||
return navigator.credentials.create({publicKey: o});
|
||||
}).then(function (cred) {
|
||||
return fetch('/api/panel/account/keys/finish', {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
name: $('.acct-keyname').value,
|
||||
clientDataJSON: b64u.enc(cred.response.clientDataJSON),
|
||||
attestationObject: b64u.enc(cred.response.attestationObject)
|
||||
})
|
||||
}).then(function (r) { return r.json(); });
|
||||
}).then(function (r) {
|
||||
if (!r.ok) { msg(r.error, true); return; }
|
||||
$('.acct-keyname').value = '';
|
||||
msg('ключ добавлен - теперь вход по нему');
|
||||
load();
|
||||
}).catch(function (e) {
|
||||
msg(e && e.name === 'NotAllowedError' ? 'отменено' : e && e.name === 'InvalidStateError' ? 'этот ключ уже добавлен' : 'не вышло: ' + (e && e.message || e), true);
|
||||
}).then(function () { add.disabled = false; });
|
||||
};
|
||||
$('.acct-pwform').onsubmit = function (ev) {
|
||||
ev.preventDefault();
|
||||
const f = ev.target;
|
||||
if (f.new.value !== f.again.value) { msg('новые пароли не совпадают', true); return; }
|
||||
f.querySelector('button').disabled = true;
|
||||
fetch('/api/panel/account/password/change', {method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({current: f.current.value, new: f.new.value})})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (r) {
|
||||
if (!r.ok) { msg(r.error, true); return; }
|
||||
f.reset();
|
||||
dlg.querySelector('.acct-chpw').open = false;
|
||||
msg('пароль сменён');
|
||||
})
|
||||
.catch(function () { msg('ошибка сети', true); })
|
||||
.then(function () { f.querySelector('button').disabled = false; });
|
||||
};
|
||||
function pinMsg(text, bad) {
|
||||
$('.acct-pinmsg').textContent = text || '';
|
||||
$('.acct-pinmsg').className = 'acct-pinmsg' + (bad ? ' bad' : '');
|
||||
}
|
||||
function savePin(pin) {
|
||||
const f = $('.acct-pinform');
|
||||
return fetch('/api/panel/account/pin', {method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({pin: pin, current: f.current.value})})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (r) {
|
||||
if (!r.ok) { pinMsg(r.error, true); return; }
|
||||
f.reset();
|
||||
// This tab stays unlocked: the PIN is for tabs opened from now on.
|
||||
try { sessionStorage.setItem('lpra.pin.ok', '1'); } catch (e) { /* asked next load */ }
|
||||
pinMsg(pin ? 'PIN сохранён' : 'PIN убран');
|
||||
load();
|
||||
})
|
||||
.catch(function () { pinMsg('ошибка сети', true); });
|
||||
}
|
||||
$('.acct-pinform').onsubmit = function (ev) {
|
||||
ev.preventDefault();
|
||||
const f = ev.target;
|
||||
if (!/^[0-9]{4}$/.test(f.pin.value)) { pinMsg('PIN - ровно 4 цифры', true); return; }
|
||||
if (f.pin.value !== f.again.value) { pinMsg('новые PIN не совпадают', true); return; }
|
||||
savePin(f.pin.value);
|
||||
};
|
||||
$('.acct-pingrace select').onchange = function (ev) {
|
||||
const min = Number(ev.target.value);
|
||||
try {
|
||||
localStorage.setItem('lpra.pin.grace', String(min));
|
||||
// Counted from now, as the PIN was entered to get here.
|
||||
localStorage.setItem('lpra.pin.until', min ? String(Date.now() + min * 60000) : '0');
|
||||
} catch (e) { pinMsg('браузер не даёт сохранить настройку', true); return; }
|
||||
pinMsg(min ? 'на этом устройстве PIN не спросят ' + min + ' мин после ввода' : 'PIN спрашивается в каждой новой вкладке');
|
||||
};
|
||||
$('.acct-pindel').onclick = function () {
|
||||
if (confirm('Убрать PIN-код?')) savePin('');
|
||||
};
|
||||
$('.acct-pw .sw').onchange = function (ev) {
|
||||
const off = !ev.target.checked;
|
||||
if (off && !confirm('Выключить вход по паролю? Войти можно будет только ключом доступа. Если ключи потеряются, их сможет сбросить другой администратор в «Настройках».')) {
|
||||
ev.target.checked = true;
|
||||
return;
|
||||
}
|
||||
fetch('/api/panel/account/password', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({off: off})})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (r) { if (!r.ok) msg(r.error, true); else msg(off ? 'вход по паролю выключен' : 'вход по паролю включён'); load(); });
|
||||
};
|
||||
btn.onclick = function () {
|
||||
msg('');
|
||||
pinMsg('');
|
||||
load().then(function () { dlg.showModal(); dlg.focus(); });
|
||||
};
|
||||
})();
|
||||
@@ -10,6 +10,8 @@
|
||||
--ok: #37c871;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
/* The hidden attribute wins over any display set by a class. */
|
||||
[hidden] { display: none !important; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
@@ -23,6 +25,13 @@ header.topbar {
|
||||
padding: 14px 24px; border-bottom: 1px solid var(--border); background: var(--panel);
|
||||
}
|
||||
header.topbar .brand { font-weight: 600; }
|
||||
header.topbar .brand a.home { color: inherit; text-decoration: none; transition: color .15s; }
|
||||
header.topbar .brand a.home:hover { color: var(--accent); }
|
||||
/* A long machine name gives way: the links and the status stay in sight. */
|
||||
@media (min-width: 641px) {
|
||||
header.topbar .brand { flex: 1 1 auto; min-width: 0; margin-right: 16px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
header.topbar nav { flex: none; white-space: nowrap; }
|
||||
}
|
||||
header.topbar nav a { margin-left: 18px; color: var(--muted); }
|
||||
header.topbar nav a:hover { color: var(--text); }
|
||||
.wrap { max-width: 960px; margin: 0 auto; padding: 24px 16px; }
|
||||
@@ -34,10 +43,24 @@ header.topbar nav a:hover { color: var(--text); }
|
||||
h1 { font-size: 22px; margin: 0 0 18px; }
|
||||
h2 { font-size: 17px; margin: 0 0 14px; }
|
||||
label { display: block; margin: 12px 0 4px; color: var(--muted); font-size: 13px; }
|
||||
input[type=text], input[type=password] {
|
||||
input[type=text], input[type=password], input[type=number] {
|
||||
width: 100%; padding: 10px 12px; border-radius: 8px;
|
||||
border: 1px solid var(--border); background: #0c0f13; color: var(--text); font-size: 14px;
|
||||
}
|
||||
/* No native spinner: it is drawn light and breaks the dark field. */
|
||||
input[type=number] { -moz-appearance: textfield; appearance: textfield; }
|
||||
input[type=number]::-webkit-inner-spin-button,
|
||||
input[type=number]::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }
|
||||
/* Sized and shaped like .btn.ghost so it lines up with the buttons next to it. */
|
||||
select {
|
||||
-webkit-appearance: none; appearance: none; cursor: pointer;
|
||||
min-width: 180px; padding: 10px 38px 10px 14px; border-radius: 8px;
|
||||
border: 1px solid var(--border); color: var(--text);
|
||||
font: inherit; font-size: 14px; font-weight: 500; line-height: 1.5;
|
||||
background: #0c0f13 url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%238b95a5' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E") no-repeat right 14px center;
|
||||
}
|
||||
select:hover, select:focus { border-color: var(--accent); outline: none; }
|
||||
select:disabled, button:disabled { opacity: .5; cursor: not-allowed; }
|
||||
button, .btn {
|
||||
display: inline-block; cursor: pointer; border: 0; border-radius: 8px;
|
||||
padding: 10px 16px; font-size: 14px; font-weight: 500;
|
||||
@@ -46,13 +69,33 @@ button, .btn {
|
||||
button:hover, .btn:hover { background: var(--accent-hover); text-decoration: none; }
|
||||
.btn.ghost { background: transparent; border: 1px solid var(--border); color: var(--text); }
|
||||
.btn.danger { background: var(--danger); }
|
||||
/* Close buttons: red text and border on the ghost button. */
|
||||
.btn.close, .btn.ghost.close { background: transparent; border: 1px solid rgba(255, 92, 92, .6); color: var(--danger); }
|
||||
.btn.close:hover, .btn.ghost.close:hover { background: #3a1d1d; border-color: var(--danger); }
|
||||
/* A close button that is just a cross: square, the height of the buttons by it. */
|
||||
.btn.close.x { width: 32px; height: 32px; padding: 0; display: inline-flex; align-items: center; justify-content: center; }
|
||||
.btn.small.close.x { width: 28px; height: 28px; }
|
||||
.btn.small { padding: 6px 10px; font-size: 13px; }
|
||||
.btn.icon { width: 30px; height: 30px; padding: 0; display: inline-flex;
|
||||
align-items: center; justify-content: center; font-size: 14px; line-height: 1; text-decoration: none; }
|
||||
/* Name of an icon button, shown at once on hover or keyboard focus. */
|
||||
[data-tip] { position: relative; }
|
||||
[data-tip]::after {
|
||||
content: attr(data-tip); position: absolute; bottom: calc(100% + 6px); left: 50%;
|
||||
transform: translateX(-50%); padding: 4px 8px; border-radius: 6px; white-space: nowrap;
|
||||
background: #0c0f13; border: 1px solid var(--border); color: var(--text);
|
||||
font-size: 12px; font-weight: 400; line-height: 1.4;
|
||||
opacity: 0; visibility: hidden; pointer-events: none; z-index: 10;
|
||||
}
|
||||
[data-tip]:hover::after, [data-tip]:focus-visible::after { opacity: 1; visibility: visible; }
|
||||
.err { background: #3a1d1d; border: 1px solid #6b2b2b; color: #ffb3b3; padding: 10px 12px; border-radius: 8px; margin-bottom: 14px; font-size: 14px; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--border); font-size: 14px; }
|
||||
th { color: var(--muted); font-weight: 500; }
|
||||
.nowrap { white-space: nowrap; }
|
||||
/* Row buttons stay on one line; the column is only as wide as they need. */
|
||||
td.actions { width: 1%; white-space: nowrap; }
|
||||
td.actions .row { flex-wrap: nowrap; justify-content: flex-end; }
|
||||
.dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; margin-right: 6px; }
|
||||
.dot.on { background: var(--ok); }
|
||||
.dot.off { background: #555d6a; }
|
||||
@@ -66,7 +109,153 @@ pre { padding: 12px; overflow-x: auto; font-size: 13px; white-space: pre-wrap; w
|
||||
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||
.spacer { flex: 1; }
|
||||
#terminal { width: 100%; height: calc(100vh - 150px); }
|
||||
@media (max-width: 600px) {
|
||||
header.topbar { padding: 12px 16px; }
|
||||
header.topbar nav a { margin-left: 12px; }
|
||||
/* The PIN lock of a new tab: the page stays hidden until it is unlocked. */
|
||||
html.pin-wait body > :not(.pinlock) { visibility: hidden; }
|
||||
.pinlock { position: fixed; inset: 0; z-index: 1000; display: flex; align-items: center; justify-content: center; padding: 16px;
|
||||
background: rgba(10, 13, 17, .92); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
|
||||
opacity: 0; transition: opacity .3s ease; }
|
||||
.pinlock.on { opacity: 1; }
|
||||
.pinlock.done { opacity: 0; pointer-events: none; }
|
||||
.pin-card { width: 100%; max-width: 300px; display: flex; flex-direction: column; align-items: center; text-align: center;
|
||||
transform: translateY(8px) scale(.98); transition: transform .3s ease; }
|
||||
.pinlock.on .pin-card { transform: none; }
|
||||
.pinlock.done .pin-card { transform: scale(1.03); }
|
||||
.pin-ico { width: 54px; height: 54px; border-radius: 50%; display: flex; align-items: center; justify-content: center;
|
||||
background: #12202f; border: 1px solid var(--border); color: var(--accent); }
|
||||
.pin-title { margin: 14px 0 18px; font-size: 18px; font-weight: 600; }
|
||||
.pin-dots { display: flex; gap: 16px; }
|
||||
.pin-dots i { width: 14px; height: 14px; border-radius: 50%; border: 2px solid var(--muted); transition: background .15s, border-color .15s, transform .15s; }
|
||||
.pin-dots i.on { background: var(--accent); border-color: var(--accent); transform: scale(1.1); }
|
||||
.pin-card.bad .pin-dots i.on { background: var(--danger); border-color: var(--danger); }
|
||||
.pin-card.bad .pin-dots { animation: pin-shake .38s ease; }
|
||||
@keyframes pin-shake { 20% { transform: translateX(-9px); } 40% { transform: translateX(8px); } 60% { transform: translateX(-5px); } 80% { transform: translateX(3px); } }
|
||||
.pin-msg { min-height: 20px; margin: 14px 0 6px; font-size: 13px; color: var(--danger); }
|
||||
.pin-pad { display: grid; grid-template-columns: repeat(3, 72px); gap: 14px; justify-content: center; }
|
||||
.pin-pad button { width: 72px; height: 72px; border-radius: 50%; padding: 0; display: inline-flex; align-items: center; justify-content: center;
|
||||
background: #151a21; border: 1px solid var(--border); color: var(--text); font-size: 26px; font-weight: 400;
|
||||
-webkit-tap-highlight-color: transparent; touch-action: manipulation; transition: background .12s, transform .08s; }
|
||||
.pin-pad button:hover { background: #1b222c; }
|
||||
.pin-pad button:active { background: #12202f; transform: scale(.94); }
|
||||
.pin-pad button[data-k="back"], .pin-pad button[data-k="out"] { background: transparent; border-color: transparent; color: #c26a6a; }
|
||||
.pin-pad button[data-k="out"] { font-size: 15px; font-weight: 500; }
|
||||
.pin-pad button[data-k="back"]:hover, .pin-pad button[data-k="out"]:hover { background: rgba(194, 106, 106, .1); }
|
||||
.pin-pad button[data-k="back"]:active, .pin-pad button[data-k="out"]:active { background: rgba(194, 106, 106, .18); }
|
||||
@media (max-height: 560px) { .pin-pad { grid-template-columns: repeat(3, 58px); gap: 10px; } .pin-pad button { width: 58px; height: 58px; font-size: 22px; } .pin-pad button[data-k="out"] { font-size: 13px; } }
|
||||
|
||||
/* Dialogs shared by the pages. */
|
||||
dialog { background: var(--panel); color: var(--text); border: 1px solid var(--border);
|
||||
border-radius: 12px; padding: 0; max-width: 900px; width: 92vw; }
|
||||
dialog::backdrop { background: rgba(0, 0, 0, .6); }
|
||||
dialog:focus { outline: none; }
|
||||
/* Dialogs come and go smoothly: rise and fade in, sink and fade out, the
|
||||
backdrop with them. Browsers without the exit transition just close. */
|
||||
dialog { opacity: 0; transform: translateY(10px) scale(.97);
|
||||
transition: opacity .2s ease, transform .2s ease, overlay .2s ease allow-discrete, display .2s ease allow-discrete; }
|
||||
dialog[open] { opacity: 1; transform: none; }
|
||||
dialog::backdrop { opacity: 0; transition: opacity .2s ease, overlay .2s ease allow-discrete, display .2s ease allow-discrete; }
|
||||
dialog[open]::backdrop { opacity: 1; }
|
||||
@starting-style {
|
||||
dialog[open] { opacity: 0; transform: translateY(10px) scale(.97); }
|
||||
dialog[open]::backdrop { opacity: 0; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { dialog, dialog::backdrop { transition: none; } }
|
||||
.dlg-h { padding: 14px 18px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 10px; }
|
||||
.dlg-b { padding: 16px 18px; }
|
||||
/* The account button in the top bar, and its dialog. */
|
||||
header.topbar nav .acct-btn { margin-left: 22px; width: 34px; height: 34px; padding: 0; vertical-align: middle; }
|
||||
#acctdlg { max-width: 480px; }
|
||||
.acct-who { display: flex; align-items: center; gap: 12px; }
|
||||
.acct-ava { width: 40px; height: 40px; border-radius: 50%; background: #12202f; border: 1px solid var(--border);
|
||||
display: inline-flex; align-items: center; justify-content: center; font-weight: 600; color: var(--accent); flex: none; }
|
||||
.acct-name { font-weight: 600; }
|
||||
.acct-mode { font-size: 13px; }
|
||||
.acct h3 { font-size: 15px; margin: 18px 0 4px; }
|
||||
.acct-hint { font-size: 13px; margin: 0 0 10px; }
|
||||
.acct-keys { list-style: none; margin: 0; padding: 0; }
|
||||
.acct-keys li { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--border); font-size: 14px; }
|
||||
.acct-keys li .muted { font-size: 12px; }
|
||||
.acct-kicon { color: var(--accent); display: inline-flex; flex: none; }
|
||||
.acct-kinfo { flex: 1; min-width: 0; overflow-wrap: anywhere; }
|
||||
.acct-add { display: flex; gap: 8px; margin-top: 12px; }
|
||||
.acct-add input { flex: 1; min-width: 0; }
|
||||
.acct-add button { flex: none; }
|
||||
.acct-msg { min-height: 20px; margin: 8px 0 0; font-size: 13px; color: var(--ok); }
|
||||
.acct-msg.bad { color: var(--danger); }
|
||||
.acct-pw { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 14px 0 0; padding-top: 12px;
|
||||
border-top: 1px solid var(--border); color: var(--text); font-size: 14px; cursor: pointer; }
|
||||
.acct-pw b { display: block; font-weight: 500; }
|
||||
.acct-pw small { display: block; font-size: 12px; margin-top: 2px; }
|
||||
/* A switch: a checkbox drawn as a sliding knob. */
|
||||
input.sw { -webkit-appearance: none; appearance: none; flex: none; width: 40px; height: 22px; margin: 0; border-radius: 11px;
|
||||
background: var(--border); position: relative; cursor: pointer; transition: background .2s; }
|
||||
input.sw::after { content: ""; position: absolute; top: 3px; left: 3px; width: 16px; height: 16px; border-radius: 50%;
|
||||
background: #fff; transition: transform .2s; }
|
||||
input.sw:checked { background: var(--accent); }
|
||||
input.sw:checked::after { transform: translateX(18px); }
|
||||
input.sw:disabled { opacity: .45; cursor: not-allowed; }
|
||||
.acct-chpw { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); }
|
||||
.acct-chpw summary { cursor: pointer; font-size: 14px; color: var(--text); }
|
||||
.acct-pwform { display: grid; gap: 8px; margin-top: 10px; }
|
||||
.acct-pwform button { justify-self: end; }
|
||||
.acct-pin { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); }
|
||||
.acct-pin summary { cursor: pointer; font-size: 14px; color: var(--text); }
|
||||
.acct-pin small { color: var(--muted); font-size: 12px; }
|
||||
.acct-pinform { display: grid; gap: 8px; margin-top: 10px; }
|
||||
.acct-pinform input:not(:placeholder-shown) { letter-spacing: .4em; font-family: ui-monospace, Menlo, monospace; }
|
||||
.acct-pinform .row { justify-content: flex-end; }
|
||||
.acct-pingrace { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 12px; font-size: 13px; color: var(--muted); }
|
||||
.acct-pingrace[hidden] { display: none; }
|
||||
.acct-pingrace select { width: auto; flex: none; }
|
||||
.acct-pinmsg { margin: 8px 0 0; font-size: 13px; color: var(--ok, #3fb950); min-height: 0; }
|
||||
.acct-pinmsg.bad { color: var(--danger); }
|
||||
.acct-out { margin-top: 10px; padding-top: 14px; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; }
|
||||
/* Sign-in page. */
|
||||
.or { display: flex; align-items: center; gap: 10px; color: var(--muted); font-size: 13px; margin: 18px 0 4px; }
|
||||
.or::before, .or::after { content: ""; flex: 1; border-top: 1px solid var(--border); }
|
||||
.or + .keybtn { margin-top: 10px; }
|
||||
.keybtn { width: 100%; display: inline-flex; align-items: center; justify-content: center; gap: 8px; }
|
||||
|
||||
/* Phones. A table marked .stack turns each row into a block of
|
||||
"label: value" lines, the labels coming from data-l on the cells; a cell
|
||||
marked .lead goes first, as the row's title. */
|
||||
@media (max-width: 640px) {
|
||||
header.topbar { flex-wrap: wrap; gap: 6px 12px; padding: 10px 12px; }
|
||||
header.topbar .brand { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; }
|
||||
header.topbar nav { display: flex; align-items: center; gap: 16px; max-width: 100%;
|
||||
overflow-x: auto; white-space: nowrap; scrollbar-width: none; }
|
||||
header.topbar nav::-webkit-scrollbar { display: none; }
|
||||
header.topbar nav > * { margin-left: 0 !important; flex: none; }
|
||||
.acct-add { flex-wrap: wrap; }
|
||||
.acct-add button { width: 100%; }
|
||||
.acct-keys li { flex-wrap: wrap; }
|
||||
.wrap { padding: 16px 12px; }
|
||||
.card { padding: 16px; border-radius: 8px; }
|
||||
h1 { font-size: 20px; }
|
||||
code { word-break: break-all; }
|
||||
/* 16px and up, or iOS zooms the page into a field on focus. */
|
||||
input[type=text], input[type=password], input[type=number], select, textarea { font-size: 16px; }
|
||||
table.stack thead { display: none; }
|
||||
table.stack, table.stack tbody { display: block; }
|
||||
table.stack tr { display: flex; flex-direction: column; gap: 2px; padding: 12px 0; border-bottom: 1px solid var(--border); }
|
||||
table.stack tr:first-child { padding-top: 0; }
|
||||
table.stack tr:last-child { border-bottom: 0; padding-bottom: 0; }
|
||||
table.stack td { display: flex; justify-content: flex-start; align-items: center; gap: 0;
|
||||
width: auto; padding: 2px 0; border: 0; text-align: right !important; overflow-wrap: anywhere; }
|
||||
table.stack td[data-l]::before { content: attr(data-l); flex: none; margin-right: auto; padding-right: 12px; color: var(--muted); font-size: 13px; text-align: left; }
|
||||
table.stack td.lead { order: -1; justify-content: flex-start; text-align: left !important; font-size: 16px; font-weight: 600; }
|
||||
table.stack td.actions { width: auto; padding-top: 8px; justify-content: flex-start; }
|
||||
table.stack td:empty { display: none; }
|
||||
}
|
||||
|
||||
/* Scrollbars as on a Mac: thin, no track, seen only while scrolling or with
|
||||
the pointer at the edge, then fading out (scroll.js sets --sb from 0 to 1).
|
||||
Nothing to scroll, nothing shown. */
|
||||
* { --sb: 0; }
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; background: transparent; }
|
||||
::-webkit-scrollbar-track, ::-webkit-scrollbar-corner { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { border-radius: 4px; border: 2px solid transparent; background-clip: padding-box;
|
||||
background-color: rgba(62, 68, 78, calc(var(--sb) * .9)); }
|
||||
::-webkit-scrollbar-thumb:hover { background-color: rgba(80, 87, 98, calc(var(--sb) * .95)); }
|
||||
@supports not selector(::-webkit-scrollbar) {
|
||||
* { scrollbar-width: thin; scrollbar-color: rgba(62, 68, 78, calc(var(--sb) * .9)) transparent; }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (C) 2017 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
@@ -0,0 +1 @@
|
||||
.CodeMirror{font-family:monospace;height:300px;color:#000;direction:ltr}.CodeMirror-lines{padding:4px 0}.CodeMirror pre.CodeMirror-line,.CodeMirror pre.CodeMirror-line-like{padding:0 4px}.CodeMirror-gutter-filler,.CodeMirror-scrollbar-filler{background-color:#fff}.CodeMirror-gutters{border-right:1px solid #ddd;background-color:#f7f7f7;white-space:nowrap}.CodeMirror-linenumber{padding:0 3px 0 5px;min-width:20px;text-align:right;color:#999;white-space:nowrap}.CodeMirror-guttermarker{color:#000}.CodeMirror-guttermarker-subtle{color:#999}.CodeMirror-cursor{border-left:1px solid #000;border-right:none;width:0}.CodeMirror div.CodeMirror-secondarycursor{border-left:1px solid silver}.cm-fat-cursor .CodeMirror-cursor{width:auto;border:0!important;background:#7e7}.cm-fat-cursor div.CodeMirror-cursors{z-index:1}.cm-fat-cursor .CodeMirror-line::selection,.cm-fat-cursor .CodeMirror-line>span::selection,.cm-fat-cursor .CodeMirror-line>span>span::selection{background:0 0}.cm-fat-cursor .CodeMirror-line::-moz-selection,.cm-fat-cursor .CodeMirror-line>span::-moz-selection,.cm-fat-cursor .CodeMirror-line>span>span::-moz-selection{background:0 0}.cm-fat-cursor{caret-color:transparent}@-moz-keyframes blink{50%{background-color:transparent}}@-webkit-keyframes blink{50%{background-color:transparent}}@keyframes blink{50%{background-color:transparent}}.cm-tab{display:inline-block;text-decoration:inherit}.CodeMirror-rulers{position:absolute;left:0;right:0;top:-50px;bottom:0;overflow:hidden}.CodeMirror-ruler{border-left:1px solid #ccc;top:0;bottom:0;position:absolute}.cm-s-default .cm-header{color:#00f}.cm-s-default .cm-quote{color:#090}.cm-negative{color:#d44}.cm-positive{color:#292}.cm-header,.cm-strong{font-weight:700}.cm-em{font-style:italic}.cm-link{text-decoration:underline}.cm-strikethrough{text-decoration:line-through}.cm-s-default .cm-keyword{color:#708}.cm-s-default .cm-atom{color:#219}.cm-s-default .cm-number{color:#164}.cm-s-default .cm-def{color:#00f}.cm-s-default .cm-variable-2{color:#05a}.cm-s-default .cm-type,.cm-s-default .cm-variable-3{color:#085}.cm-s-default .cm-comment{color:#a50}.cm-s-default .cm-string{color:#a11}.cm-s-default .cm-string-2{color:#f50}.cm-s-default .cm-meta{color:#555}.cm-s-default .cm-qualifier{color:#555}.cm-s-default .cm-builtin{color:#30a}.cm-s-default .cm-bracket{color:#997}.cm-s-default .cm-tag{color:#170}.cm-s-default .cm-attribute{color:#00c}.cm-s-default .cm-hr{color:#999}.cm-s-default .cm-link{color:#00c}.cm-s-default .cm-error{color:red}.cm-invalidchar{color:red}.CodeMirror-composing{border-bottom:2px solid}div.CodeMirror span.CodeMirror-matchingbracket{color:#0b0}div.CodeMirror span.CodeMirror-nonmatchingbracket{color:#a22}.CodeMirror-matchingtag{background:rgba(255,150,0,.3)}.CodeMirror-activeline-background{background:#e8f2ff}.CodeMirror{position:relative;overflow:hidden;background:#fff}.CodeMirror-scroll{overflow:scroll!important;margin-bottom:-50px;margin-right:-50px;padding-bottom:50px;height:100%;outline:0;position:relative;z-index:0}.CodeMirror-sizer{position:relative;border-right:50px solid transparent}.CodeMirror-gutter-filler,.CodeMirror-hscrollbar,.CodeMirror-scrollbar-filler,.CodeMirror-vscrollbar{position:absolute;z-index:6;display:none;outline:0}.CodeMirror-vscrollbar{right:0;top:0;overflow-x:hidden;overflow-y:scroll}.CodeMirror-hscrollbar{bottom:0;left:0;overflow-y:hidden;overflow-x:scroll}.CodeMirror-scrollbar-filler{right:0;bottom:0}.CodeMirror-gutter-filler{left:0;bottom:0}.CodeMirror-gutters{position:absolute;left:0;top:0;min-height:100%;z-index:3}.CodeMirror-gutter{white-space:normal;height:100%;display:inline-block;vertical-align:top;margin-bottom:-50px}.CodeMirror-gutter-wrapper{position:absolute;z-index:4;background:0 0!important;border:none!important}.CodeMirror-gutter-background{position:absolute;top:0;bottom:0;z-index:4}.CodeMirror-gutter-elt{position:absolute;cursor:default;z-index:4}.CodeMirror-gutter-wrapper ::selection{background-color:transparent}.CodeMirror-gutter-wrapper ::-moz-selection{background-color:transparent}.CodeMirror-lines{cursor:text;min-height:1px}.CodeMirror pre.CodeMirror-line,.CodeMirror pre.CodeMirror-line-like{-moz-border-radius:0;-webkit-border-radius:0;border-radius:0;border-width:0;background:0 0;font-family:inherit;font-size:inherit;margin:0;white-space:pre;word-wrap:normal;line-height:inherit;color:inherit;z-index:2;position:relative;overflow:visible;-webkit-tap-highlight-color:transparent;-webkit-font-variant-ligatures:contextual;font-variant-ligatures:contextual}.CodeMirror-wrap pre.CodeMirror-line,.CodeMirror-wrap pre.CodeMirror-line-like{word-wrap:break-word;white-space:pre-wrap;word-break:normal}.CodeMirror-linebackground{position:absolute;left:0;right:0;top:0;bottom:0;z-index:0}.CodeMirror-linewidget{position:relative;z-index:2;padding:.1px}.CodeMirror-rtl pre{direction:rtl}.CodeMirror-code{outline:0}.CodeMirror-gutter,.CodeMirror-gutters,.CodeMirror-linenumber,.CodeMirror-scroll,.CodeMirror-sizer{-moz-box-sizing:contentLine truncated
|
||||
@@ -0,0 +1,27 @@
|
||||
/* CodeMirror 5.65.16 (MIT, see LICENSE) with the addons and language modes the file editor uses, joined into one file. */
|
||||
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e=e||self).CodeMirror=t()}(this,function(){"use strict";var e=navigator.userAgent,l=navigator.platform,d=/gecko\/\d/i.test(e),s=/MSIE \d/.test(e),a=/Trident\/(?:[7-9]|\d{2,})\..*rv:(\d+)/.exec(e),u=/Edge\/(\d+)/.exec(e),w=s||a||u,v=w&&(s?document.documentMode||6:+(u||a)[1]),x=!u&&/WebKit\//.test(e),s=x&&/Qt\/\d+\.\d+/.test(e),m=!u&&/Chrome\/(\d+)/.exec(e),V=m&&+m[1],K=/Opera\//.test(e),j=/Apple Computer/.test(navigator.vendor),c=/Mac OS X 1\d\D([8-9]|\d\d)\D/.test(e),X=/PhantomJS/.test(e),Y=j&&(/Mobile\/\w+/.test(e)||2<navigator.maxTouchPoints),$=/Android/.test(e),_=Y||$||/webOS|BlackBerry|Opera Mini|Opera Mobi|IEMobile/i.test(e),C=Y||/Mac/.test(l),q=/\bCrOS\b/.test(e),a=/win/i.test(l),u=K&&e.match(/Version\/(\d*\.\d*)/),Z=((u=u&&Number(u[1]))&&15<=u&&(x=!(K=!1)),C&&(s||K&&(null==u||u<12.11))),Q=d||w&&9<=v;function J(e){return new RegExp("(^|\\s)"+e+"(?:$|\\s)\\s*")}var ee=function(e,t){var n,r=e.className,t=J(t).exec(r);t&&(n=r.slice(t.index+t[0].length),e.className=r.slice(0,t.index)+(n?t[1]+n:""))};function te(e){for(var t=e.childNodes.length;0<t;--t)e.removeChild(e.firstChild);return e}function y(e,t){return te(e).appendChild(t)}function M(e,t,n,r){var i=document.createElement(e);if(n&&(i.className=n),r&&(i.style.cssText=r),"string"==typeof t)i.appendChild(document.createTextNode(t));else if(t)for(var o=0;o<t.length;++o)i.appendChild(t[o]);return i}function ne(e,t,n,r){e=M(e,t,n,r);return e.setAttribute("role","presentation"),e}function re(e,t){if(3==t.nodeType&&(t=t.parentNode),e.contains)return e.contains(t);do{if((t=11==t.nodeType?t.host:t)==e)return!0}while(t=t.parentNode)}function N(e){var t,n=e.ownerDocument||e;try{t=e.activeElement}catch(e){t=n.body||null}for(;t&&t.shadowRoot&&t.shadowRoot.activeElement;)t=t.shadowRoot.activeElement;return t}function ie(e,t){var n=e.className;J(t).test(n)||(e.className+=(n?" ":"")+t)}function oe(e,t){for(var n=e.split(" "),r=0;r<n.length;r++)n[r]&&!J(n[r]).test(t)&&(t+=" "+n[r]);return t}var le=document.createRange?function(e,t,n,r){var i=document.createRange();return i.setEnd(r||e,n),i.setStart(e,t),i}:function(e,t,n){var r=document.body.createTextRange();try{r.moveToElementText(e.parentNode)}catch(e){return r}return r.collapse(!0),r.moveEnd("character",n),r.moveStart("character",t),r},se=function(e){e.select()};function ae(e){return e.display.wrapper.ownerDocument}function ue(e){return ce(e.display.wrapper)}function ce(e){return e.getRootNode?e.getRootNode():e.ownerDocument}function he(e){return ae(e).defaultView}function de(e){var t=Array.prototype.slice.call(arguments,1);return function(){return e.apply(null,t)}}function fe(e,t,n){for(var r in t=t||{},e)!e.hasOwnProperty(r)||!1===n&&t.hasOwnProperty(r)||(t[r]=e[r]);return t}function S(e,t,n,r,i){null==t&&-1==(t=e.search(/[^\s\u00a0]/))&&(t=e.length);for(var o=r||0,l=i||0;;){var s=e.indexOf("\t",o);if(s<0||t<=s)return l+(t-o);l=(l+=s-o)+(n-l%n),o=s+1}}Y?se=function(e){e.selectionStart=0,e.selectionEnd=e.value.length}:w&&(se=function(e){try{e.select()}catch(e){}});function pe(){this.id=null,this.f=null,this.time=0,this.handler=de(this.onTimeout,this)}function L(e,t){for(var n=0;n<e.length;++n)if(e[n]==t)return n;return-1}pe.prototype.onTimeout=function(e){e.id=0,e.time<=+new Date?e.f():setTimeout(e.handler,e.time-+new Date)};var ge=50,me={toString:function(){return"CodeMirror.Pass"}},ve={scroll:!(pe.prototype.set=function(e,t){this.f=t;t=+new Date+e;(!this.id||t<this.time)&&(clearTimeout(this.id),this.id=setTimeout(this.handler,e),this.time=t)})},ye={origin:"*mouse"},be={origin:"+move"};function we(e,t,n){for(var r=0,i=0;;){var o=e.indexOf("\t",r),l=(o=-1==o?e.length:o)-r;if(o==e.length||t<=i+l)return r+Math.min(l,t-i);if(i+=o-r,r=o+1,t<=(i+=n-i%n))return r}}var xe=[""];function Ce(e){for(;xe.length<=e;)xe.push(z(xe)+" ");return xe[e]}function z(e){return e[e.length-1]}function Se(e,t){for(var n=[],r=0;r<e.length;r++)n[r]=t(e[r],r);return n}function Le(){}function ke(e,t){e=Object.create?Object.create(e):(Le.prototype=e,new Le);return t&&fe(t,e),e}var Te=/[\u00df\u0587\u0590-\u05f4\u0600-\u06ff\u3040-\u309f\u30a0-\u30ff\u3400-\u4db5\u4e00-\u9fcc\uac00-\ud7af]/;function Me(e){return/\w/.test(e)||""<e&&(e.toUpperCase()!=e.toLowerCase()||Te.test(e))}function Ne(e,t){return t?!!(-1<t.source.indexOf("\\w")&&Me(e))||t.test(e):Me(e)}function Oe(e){for(var t in e)if(e.hasOwnProperty(t)&&e[t])return;return 1}var Ae=/[\u0300-\u036f\u0483-\u0489\u0591-\u05bd\u05bf\u05c1\u05c2\u05c4\u05c5\u05c7\u0610-\u061a\u064b-\u065e\u0670\u06d6-\u06dc\u06de-\u06e4\u06e7\u06e8\u06ea-\u06ed\u0711\u0730-\u074a\u07a6-\u07b0\u07eb-\u07f3\u0816-\u0819\u081b-\u0823\u0825-\u0827\u0829-\u082d\u0900-\u0902\u093c\u0941-\u0948\u094d\u0951-\u0955\u0962\u0963\u0981\u09bc\u09be\u09c1-\u09c4\u09cd\u09d7\u09e2\u09e3\u0a01\u0a02\u0a3c\u0a41\u0a42\u0a47\u0a48\u0a4b-\u0a4d\u0a51\u0a70\u0a71\u0a75\u0a81\u0a82\u0abc\u0acLine truncated
|
||||
!function(t){"object"==typeof exports&&"object"==typeof module?t(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],t):t(CodeMirror)}(function(r){"use strict";var p,v,x=r.Pos;function m(t,e){for(var n,i=null!=(i=(n=t).flags)?i:(n.ignoreCase?"i":"")+(n.global?"g":"")+(n.multiline?"m":""),r=i,o=0;o<e.length;o++)-1==r.indexOf(e.charAt(o))&&(r+=e.charAt(o));return i==r?t:new RegExp(t.source,r)}function d(t){return/\\s|\\n|\n|\\W|\\D|\[\^/.test(t.source)}function a(t,e,n){e=m(e,"g");for(var i=n.line,r=n.ch,o=t.lastLine();i<=o;i++,r=0){e.lastIndex=r;var h=t.getLine(i),h=e.exec(h);if(h)return{from:x(i,h.index),to:x(i,h.index+h[0].length),match:h}}}function o(t,e,n){if(!d(e))return a(t,e,n);e=m(e,"gm");for(var i=1,r=n.line,o=t.lastLine();r<=o;){for(var h=0;h<i&&!(o<r);h++)var l=t.getLine(r++),c=null==c?l:c+"\n"+l;i*=2,e.lastIndex=n.ch;var s,f,g,u=e.exec(c);if(u)return g=c.slice(0,u.index).split("\n"),s=u[0].split("\n"),f=n.line+g.length-1,g=g[g.length-1].length,{from:x(f,g),to:x(f+s.length-1,1==s.length?g+s[0].length:s[s.length-1].length),match:u}}}function L(t,e,n){for(var i,r=0;r<=t.length;){e.lastIndex=r;var o=e.exec(t);if(!o)break;var h=o.index+o[0].length;if(h>t.length-n)break;(!i||h>i.index+i[0].length)&&(i=o),r=o.index+1}return i}function O(t,e,n){e=m(e,"g");for(var i=n.line,r=n.ch,o=t.firstLine();o<=i;i--,r=-1){var h=t.getLine(i),h=L(h,e,r<0?0:h.length-r);if(h)return{from:x(i,h.index),to:x(i,h.index+h[0].length),match:h}}}function h(t,e,n){if(!d(e))return O(t,e,n);e=m(e,"gm");for(var i=1,r=t.getLine(n.line).length-n.ch,o=n.line,h=t.firstLine();h<=o;){for(var l=0;l<i&&h<=o;l++)var c=t.getLine(o--),s=null==s?c:c+"\n"+s;i*=2;var f,g,u,a=L(s,e,r);if(a)return u=s.slice(0,a.index).split("\n"),f=a[0].split("\n"),g=o+u.length,u=u[u.length-1].length,{from:x(g,u),to:x(g+f.length-1,1==f.length?u+f[0].length:f[f.length-1].length),match:a}}}function y(t,e,n,i){if(t.length==e.length)return n;for(var r=0,o=n+Math.max(0,t.length-e.length);;){if(r==o)return r;var h=r+o>>1,l=i(t.slice(0,h)).length;if(l==n)return h;n<l?o=h:r=1+h}}function l(t,e,n,i){if(!e.length)return null;var r=i?p:v,o=r(e).split(/\r|\n\r?/);t:for(var h=n.line,l=n.ch,c=t.lastLine()+1-o.length;h<=c;h++,l=0){var s=t.getLine(h).slice(l),f=r(s);if(1==o.length){var g=f.indexOf(o[0]);if(-1==g);else return n=y(s,f,g,r)+l,{from:x(h,y(s,f,g,r)+l),to:x(h,y(s,f,g+o[0].length,r)+l)}}else{g=f.length-o[0].length;if(f.slice(g)==o[0]){for(var u=1;u<o.length-1;u++)if(r(t.getLine(h+u))!=o[u])continue t;var a=t.getLine(h+o.length-1),m=r(a),d=o[o.length-1];if(m.slice(0,d.length)==d)return{from:x(h,y(s,f,g,r)+l),to:x(h+o.length-1,y(a,m,d.length,r))}}}}}function c(t,e,n,i){if(!e.length)return null;var r=i?p:v,o=r(e).split(/\r|\n\r?/);t:for(var h=n.line,l=n.ch,c=t.firstLine()-1+o.length;c<=h;h--,l=-1){var s=t.getLine(h),f=r(s=-1<l?s.slice(0,l):s);if(1==o.length){var g=f.lastIndexOf(o[0]);if(-1!=g)return{from:x(h,y(s,f,g,r)),to:x(h,y(s,f,g+o[0].length,r))}}else{g=o[o.length-1];if(f.slice(0,g.length)==g){for(var u=1,n=h-o.length+1;u<o.length-1;u++)if(r(t.getLine(n+u))!=o[u])continue t;var a=t.getLine(h+1-o.length),m=r(a);if(m.slice(m.length-o[0].length)==o[0])return{from:x(h+1-o.length,y(a,m,a.length-o[0].length,r)),to:x(h,y(s,f,g.length,r))}}}}}function i(n,i,t,e){var r;this.atOccurrence=!1,this.afterEmptyMatch=!1,this.doc=n,t=t?n.clipPos(t):x(0,0),this.pos={from:t,to:t},"object"==typeof e?r=e.caseFold:(r=e,e=null),"string"==typeof i?(null==r&&(r=!1),this.matches=function(t,e){return(t?c:l)(n,i,e,r)}):(i=m(i,"gm"),e&&!1===e.multiline?this.matches=function(t,e){return(t?O:a)(n,i,e)}:this.matches=function(t,e){return(t?h:o)(n,i,e)})}v=String.prototype.normalize?(p=function(t){return t.normalize("NFD").toLowerCase()},function(t){return t.normalize("NFD")}):(p=function(t){return t.toLowerCase()},function(t){return t}),i.prototype={findNext:function(){return this.find(!1)},findPrevious:function(){return this.find(!0)},find:function(t){var e=this.doc.clipPos(t?this.pos.from:this.pos.to);if(this.afterEmptyMatch&&this.atOccurrence&&(e=x(e.line,e.ch),t?(e.ch--,e.ch<0&&(e.line--,e.ch=(this.doc.getLine(e.line)||"").length)):(e.ch++,e.ch>(this.doc.getLine(e.line)||"").length&&(e.ch=0,e.line++)),0!=r.cmpPos(e,this.doc.clipPos(e))))return this.atOccurrence=!1;var e=this.matches(t,e);return this.afterEmptyMatch=e&&0==r.cmpPos(e.from,e.to),e?(this.pos=e,this.atOccurrence=!0,this.pos.match||!0):(e=x(t?this.doc.firstLine():this.doc.lastLine()+1,0),this.pos={from:e,to:e},this.atOccurrence=!1)},from:function(){if(this.atOccurrence)return this.pos.from},to:function(){if(this.atOccurrence)return this.pos.to},replace:function(t,e){this.atOccurrence&&(t=r.splitLines(t),this.doc.replaceRange(t,this.pos.from,this.pos.to,e),this.pos.to=x(this.pos.from.line+t.length-1,t[t.length-1].length+(1==t.length?this.pos.from.ch:0)))}},r.defineExtension("getSearchCursor",function(t,e,n){return new i(this.doc,t,e,n)}),r.defineDocExtension("getSearchCursor",function(t,Line truncated
|
||||
!function(t){"object"==typeof exports&&"object"==typeof module?t(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],t):t(CodeMirror)}(function(r){var u=/MSIE \d/.test(navigator.userAgent)&&(null==document.documentMode||document.documentMode<8),k=r.Pos,p={"(":")>",")":"(<","[":"]>","]":"[<","{":"}>","}":"{<","<":">>",">":"<<"};function y(t){return t&&t.bracketRegex||/[(){}[\]]/}function f(t,e,n){var r=t.getLineHandle(e.line),i=e.ch-1,c=n&&n.afterCursor,a=(null==c&&(c=/(^| )cm-fat-cursor($| )/.test(t.getWrapperElement().className)),y(n)),c=!c&&0<=i&&a.test(r.text.charAt(i))&&p[r.text.charAt(i)]||a.test(r.text.charAt(i+1))&&p[r.text.charAt(++i)];if(!c)return null;a=">"==c.charAt(1)?1:-1;if(n&&n.strict&&0<a!=(i==e.ch))return null;r=t.getTokenTypeAt(k(e.line,i+1)),t=o(t,k(e.line,i+(0<a?1:0)),a,r,n);return null==t?null:{from:k(e.line,i),to:t&&t.pos,match:t&&t.ch==c.charAt(0),forward:0<a}}function o(t,e,n,r,i){for(var c=i&&i.maxScanLineLength||1e4,a=i&&i.maxScanLines||1e3,o=[],h=y(i),l=0<n?Math.min(e.line+a,t.lastLine()+1):Math.max(t.firstLine()-1,e.line-a),s=e.line;s!=l;s+=n){var u=t.getLine(s);if(u){var f=0<n?0:u.length-1,m=0<n?u.length:-1;if(!(u.length>c))for(s==e.line&&(f=e.ch-(n<0?1:0));f!=m;f+=n){var g=u.charAt(f);if(h.test(g)&&(void 0===r||(t.getTokenTypeAt(k(s,f+1))||"")==(r||""))){var d=p[g];if(d&&">"==d.charAt(1)==0<n)o.push(g);else{if(!o.length)return{pos:k(s,f),ch:g};o.pop()}}}}}return s-n!=(0<n?t.lastLine():t.firstLine())&&null}function e(t,e,n){for(var r=t.state.matchBrackets.maxHighlightLineLength||1e3,i=n&&n.highlightNonMatching,c=[],a=t.listSelections(),o=0;o<a.length;o++){var h,l=a[o].empty()&&f(t,a[o].head,n);l&&(l.match||!1!==i)&&t.getLine(l.from.line).length<=r&&(h=l.match?"CodeMirror-matchingbracket":"CodeMirror-nonmatchingbracket",c.push(t.markText(l.from,k(l.from.line,l.from.ch+1),{className:h})),l.to&&t.getLine(l.to.line).length<=r&&c.push(t.markText(l.to,k(l.to.line,l.to.ch+1),{className:h})))}if(c.length){u&&t.state.focused&&t.focus();function s(){t.operation(function(){for(var t=0;t<c.length;t++)c[t].clear()})}if(!e)return s;setTimeout(s,800)}}function i(t){t.operation(function(){t.state.matchBrackets.currentlyHighlighted&&(t.state.matchBrackets.currentlyHighlighted(),t.state.matchBrackets.currentlyHighlighted=null),t.state.matchBrackets.currentlyHighlighted=e(t,!1,t.state.matchBrackets)})}function c(t){t.state.matchBrackets&&t.state.matchBrackets.currentlyHighlighted&&(t.state.matchBrackets.currentlyHighlighted(),t.state.matchBrackets.currentlyHighlighted=null)}r.defineOption("matchBrackets",!1,function(t,e,n){n&&n!=r.Init&&(t.off("cursorActivity",i),t.off("focus",i),t.off("blur",c),c(t)),e&&(t.state.matchBrackets="object"==typeof e?e:{},t.on("cursorActivity",i),t.on("focus",i),t.on("blur",c))}),r.defineExtension("matchBrackets",function(){e(this,!0)}),r.defineExtension("findMatchingBracket",function(t,e,n){return f(this,t,e=!n&&"boolean"!=typeof e?e:n?(n.strict=e,n):e?{strict:!0}:null)}),r.defineExtension("scanForBracket",function(t,e,n,r){return o(this,t,e,n,r)})});;
|
||||
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(v){"use strict";function h(e,t){if(!e.hasOwnProperty(t))throw new Error("Undefined state "+t+" in simple mode")}function k(e,t){if(!e)return/(?:)/;var n="";return e=e instanceof RegExp?(e.ignoreCase&&(n="i"),e.unicode&&(n+="u"),e.source):String(e),new RegExp((!1===t?"":"^")+"(?:"+e+")",n)}function g(e,t){(e.next||e.push)&&h(t,e.next||e.push),this.regex=k(e.regex),this.token=function(e){if(!e)return null;if(e.apply)return e;if("string"==typeof e)return e.replace(/\./g," ");for(var t=[],n=0;n<e.length;n++)t.push(e[n]&&e[n].replace(/\./g," "));return t}(e.token),this.data=e}v.defineSimpleMode=function(e,t){v.defineMode(e,function(e){return v.simpleMode(e,t)})},v.simpleMode=function(e,t){h(t,"start");var n,a={},o=t.meta||{},r=!1;for(n in t)if(n!=o&&t.hasOwnProperty(n))for(var i=a[n]=[],l=t[n],s=0;s<l.length;s++){var d=l[s];i.push(new g(d,t)),(d.indent||d.dedent)&&(r=!0)}var c,p,S,m,u={startState:function(){return{state:"start",pending:null,local:null,localState:null,indent:r?[]:null}},copyState:function(e){var t={state:e.state,pending:e.pending,local:e.local,localState:null,indent:e.indent&&e.indent.slice(0)};e.localState&&(t.localState=v.copyState(e.local.mode,e.localState)),e.stack&&(t.stack=e.stack.slice(0));for(var n=e.persistentStates;n;n=n.next)t.persistentStates={mode:n.mode,spec:n.spec,state:n.state==e.localState?t.localState:v.copyState(n.mode,n.state),next:t.persistentStates};return t},token:(m=e,function(e,t){var n,a;if(t.pending)return a=t.pending.shift(),0==t.pending.length&&(t.pending=null),e.pos+=a.text.length,a.token;if(t.local)return t.local.end&&e.match(t.local.end)?(n=t.local.endToken||null,t.local=t.localState=null):(n=t.local.mode.token(e,t.localState),t.local.endScan&&(a=t.local.endScan.exec(e.current()))&&(e.pos=e.start+a.index)),n;for(var o=S[t.state],r=0;r<o.length;r++){var i=o[r],l=(!i.data.sol||e.sol())&&e.match(i.regex);if(l){if(i.data.next?t.state=i.data.next:i.data.push?((t.stack||(t.stack=[])).push(t.state),t.state=i.data.push):i.data.pop&&t.stack&&t.stack.length&&(t.state=t.stack.pop()),i.data.mode){h=d=f=s=u=p=c=d=void 0;var s,d=m,c=t,p=i.data.mode,u=i.token;if(p.persistent)for(var f=c.persistentStates;f&&!s;f=f.next)(p.spec?function e(t,n){if(t===n)return!0;if(!t||"object"!=typeof t||!n||"object"!=typeof n)return!1;var a=0;for(var o in t)if(t.hasOwnProperty(o)){if(!n.hasOwnProperty(o)||!e(t[o],n[o]))return!1;a++}for(var o in n)n.hasOwnProperty(o)&&a--;return 0==a}(p.spec,f.spec):p.mode==f.mode)&&(s=f);var d=s?s.mode:p.mode||v.getMode(d,p.spec),h=s?s.state:v.startState(d);p.persistent&&!s&&(c.persistentStates={mode:d,spec:p.spec,state:h,next:c.persistentStates}),c.localState=h,c.local={mode:d,end:p.end&&k(p.end),endScan:p.end&&!1!==p.forceEnd&&k(p.end,!1),endToken:u&&u.join?u[u.length-1]:u}}i.data.indent&&t.indent.push(e.indentation()+m.indentUnit),i.data.dedent&&t.indent.pop();h=i.token;if(h&&h.apply&&(h=h(l)),2<l.length&&i.token&&"string"!=typeof i.token){for(var g=2;g<l.length;g++)l[g]&&(t.pending||(t.pending=[])).push({text:l[g],token:i.token[g-1]});return e.backUp(l[0].length-(l[1]?l[1].length:0)),h[0]}return h&&h.join?h[0]:h}}return e.next(),null}),innerMode:function(e){return e.local&&{mode:e.local.mode,state:e.localState}},indent:(c=S=a,function(e,t,n){if(e.local&&e.local.mode.indent)return e.local.mode.indent(e.localState,t,n);if(null==e.indent||e.local||p.dontIndentStates&&-1<function(e,t){for(var n=0;n<t.length;n++)if(t[n]===e)return!0}(e.state,p.dontIndentStates))return v.Pass;var a=e.indent.length-1,o=c[e.state];e:for(;;){for(var r=0;r<o.length;r++){var i=o[r];if(i.data.dedent&&!1!==i.data.dedentIfLineStart){var l=i.regex.exec(t);if(l&&l[0]){a--,(i.next||i.push)&&(o=c[i.next||i.push]),t=t.slice(l[0].length);continue e}}}break}return a<0?0:e.indent[a]})};if(p=o)for(var f in o)o.hasOwnProperty(f)&&(u[f]=o[f]);return u}});;
|
||||
!function(t){"object"==typeof exports&&"object"==typeof module?t(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],t):t(CodeMirror)}(function(y){"use strict";var C={autoSelfClosers:{area:!0,base:!0,br:!0,col:!0,command:!0,embed:!0,frame:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0,menuitem:!0},implicitlyClosed:{dd:!0,li:!0,optgroup:!0,option:!0,p:!0,rp:!0,rt:!0,tbody:!0,td:!0,tfoot:!0,th:!0,tr:!0},contextGrabbers:{dd:{dd:!0,dt:!0},dt:{dd:!0,dt:!0},li:{li:!0},option:{option:!0,optgroup:!0},optgroup:{optgroup:!0},p:{address:!0,article:!0,aside:!0,blockquote:!0,dir:!0,div:!0,dl:!0,fieldset:!0,footer:!0,form:!0,h1:!0,h2:!0,h3:!0,h4:!0,h5:!0,h6:!0,header:!0,hgroup:!0,hr:!0,menu:!0,nav:!0,ol:!0,p:!0,pre:!0,section:!0,table:!0,ul:!0},rp:{rp:!0,rt:!0},rt:{rp:!0,rt:!0},tbody:{tbody:!0,tfoot:!0},td:{td:!0,th:!0},tfoot:{tbody:!0},th:{td:!0,th:!0},thead:{tbody:!0,tfoot:!0},tr:{tr:!0}},doNotIndent:{pre:!0},allowUnquoted:!0,allowMissing:!0,caseFold:!0},z={autoSelfClosers:{},implicitlyClosed:{},contextGrabbers:{},doNotIndent:{},allowUnquoted:!1,allowMissing:!1,allowMissingTagName:!1,caseFold:!1};y.defineMode("xml",function(t,e){var n,i,a,l=t.indentUnit,u={},r=e.htmlMode?C:z;for(n in r)u[n]=r[n];for(n in e)u[n]=e[n];function c(e,n){function t(t){return(n.tokenize=t)(e,n)}var r=e.next();return"<"==r?e.eat("!")?e.eat("[")?e.match("CDATA[")?t(o("atom","]]>")):null:e.match("--")?t(o("comment","--\x3e")):e.match("DOCTYPE",!0,!0)?(e.eatWhile(/[\w\._\-]/),t(function r(o){return function(t,e){for(var n;null!=(n=t.next());){if("<"==n)return e.tokenize=r(o+1),e.tokenize(t,e);if(">"==n){if(1!=o)return e.tokenize=r(o-1),e.tokenize(t,e);e.tokenize=c;break}}return"meta"}}(1))):null:e.eat("?")?(e.eatWhile(/[\w\._\-]/),n.tokenize=o("meta","?>"),"meta"):(i=e.eat("/")?"closeTag":"openTag",n.tokenize=d,"tag bracket"):"&"==r?(e.eat("#")?e.eat("x")?e.eatWhile(/[a-fA-F\d]/)&&e.eat(";"):e.eatWhile(/[\d]/)&&e.eat(";"):e.eatWhile(/[\w\.\-:]/)&&e.eat(";"))?"atom":"error":(e.eatWhile(/[^&<]/),null)}function d(t,e){var n,r,o=t.next();return">"==o||"/"==o&&t.eat(">")?(e.tokenize=c,i=">"==o?"endTag":"selfcloseTag","tag bracket"):"="==o?(i="equals",null):"<"==o?(e.tokenize=c,e.state=p,e.tagName=e.tagStart=null,(n=e.tokenize(t,e))?n+" tag error":"tag error"):/[\'\"]/.test(o)?(e.tokenize=(r=o,a.isInAttribute=!0,a),e.stringStartCol=t.column(),e.tokenize(t,e)):(t.match(/^[^\s\u00a0=<>\"\']*[^\s\u00a0=<>\"\'\/]/),"word");function a(t,e){for(;!t.eol();)if(t.next()==r){e.tokenize=d;break}return"string"}}function o(n,r){return function(t,e){for(;!t.eol();){if(t.match(r)){e.tokenize=c;break}t.next()}return n}}function s(t){return t&&t.toLowerCase()}function f(t,e,n){this.prev=t.context,this.tagName=e||"",this.indent=t.indented,this.startOfLine=n,(u.doNotIndent.hasOwnProperty(e)||t.context&&t.context.noIndent)&&(this.noIndent=!0)}function m(t){t.context&&(t.context=t.context.prev)}function g(t,e){for(var n;;){if(!t.context)return;if(n=t.context.tagName,!u.contextGrabbers.hasOwnProperty(s(n))||!u.contextGrabbers[s(n)].hasOwnProperty(s(e)))return;m(t)}}function p(t,e,n){return"openTag"==t?(n.tagStart=e.column(),h):"closeTag"==t?x:p}function h(t,e,n){return"word"==t?(n.tagName=e.current(),a="tag",w):u.allowMissingTagName&&"endTag"==t?(a="tag bracket",w(t,0,n)):(a="error",h)}function x(t,e,n){var r;return"word"==t?(r=e.current(),n.context&&n.context.tagName!=r&&u.implicitlyClosed.hasOwnProperty(s(n.context.tagName))&&m(n),n.context&&n.context.tagName==r||!1===u.matchClosing?(a="tag",b):(a="tag error",k)):u.allowMissingTagName&&"endTag"==t?(a="tag bracket",b(t,0,n)):(a="error",k)}function b(t,e,n){return"endTag"!=t?(a="error",b):(m(n),p)}function k(t,e,n){return a="error",b(t,0,n)}function w(t,e,n){return"word"==t?(a="attribute",T):"endTag"==t||"selfcloseTag"==t?(r=n.tagName,o=n.tagStart,n.tagName=n.tagStart=null,"selfcloseTag"==t||u.autoSelfClosers.hasOwnProperty(s(r))?g(n,r):(g(n,r),n.context=new f(n,r,o==n.indented)),p):(a="error",w);var r,o}function T(t,e,n){return"equals"==t?v:(u.allowMissing||(a="error"),w(t,0,n))}function v(t,e,n){return"string"==t?N:"word"==t&&u.allowUnquoted?(a="string",w):(a="error",w(t,0,n))}function N(t,e,n){return"string"==t?N:w(t,0,n)}return c.isInText=!0,{startState:function(t){var e={tokenize:c,state:p,indented:t||0,tagName:null,tagStart:null,context:null};return null!=t&&(e.baseIndent=t),e},token:function(t,e){if(!e.tagName&&t.sol()&&(e.indented=t.indentation()),t.eatSpace())return null;i=null;var n=e.tokenize(t,e);return(n||i)&&"comment"!=n&&(a=null,e.state=e.state(i||n,t,e),a&&(n="error"==a?n+" error":a)),n},indent:function(t,e,n){var r=t.context;if(t.tokenize.isInAttribute)return t.tagStart==t.indented?t.stringStartCol+1:t.indented+l;if(r&&r.noIndent)return y.Pass;if(t.tokenize!=d&&t.tokenize!=c)return n?n.match(/^(\s*)/)[0].length:0;if(t.tagName)return!1!==u.multilineTagIndentPastTag?t.tagStart+t.tagName.length+2:t.tagStart+l*(u.multilinLine truncated
|
||||
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(rt){"use strict";rt.defineMode("javascript",function(e,l){var t,r,O,P,f=e.indentUnit,N=l.statementIndent,U=l.jsonld,o=l.json||U,W=!1!==l.trackScope,u=l.typescript,B=l.wordCharacters||/[\w$\xa1-\uffff]/,F=(e=n("keyword a"),t=n("keyword b"),r=n("keyword c"),O=n("keyword d"),P=n("operator"),{if:n("if"),while:e,with:e,else:t,do:t,try:t,finally:t,return:O,break:O,continue:O,new:n("new"),delete:r,void:r,throw:r,debugger:n("debugger"),var:n("var"),const:n("var"),let:n("var"),function:n("function"),catch:n("catch"),for:n("for"),switch:n("switch"),case:n("case"),default:n("default"),in:P,typeof:P,instanceof:P,true:e={type:"atom",style:"atom"},false:e,null:e,undefined:e,NaN:e,Infinity:e,this:n("this"),class:n("class"),super:n("atom"),yield:r,export:n("export"),import:n("import"),extends:r,await:r});function n(e){return{type:e,style:"keyword"}}var H,D,G=/[+\-*&%=<>!?|~^@]/,J=/^@(context|id|value|language|type|container|list|set|reverse|index|base|vocab|graph)"/;function i(e,t,r){return H=e,D=r,t}function d(e,t){var a,r=e.next();if('"'==r||"'"==r)return t.tokenize=(a=r,function(e,t){var r,n=!1;if(U&&"@"==e.peek()&&e.match(J))return t.tokenize=d,i("jsonld-keyword","meta");for(;null!=(r=e.next())&&(r!=a||n);)n=!n&&"\\"==r;return n||(t.tokenize=d),i("string","string")}),t.tokenize(e,t);if("."==r&&e.match(/^\d[\d_]*(?:[eE][+\-]?[\d_]+)?/))return i("number","number");if("."==r&&e.match(".."))return i("spread","meta");if(/[\[\]{}\(\),;\:\.]/.test(r))return i(r);if("="==r&&e.eat(">"))return i("=>","operator");if("0"==r&&e.match(/^(?:x[\dA-Fa-f_]+|o[0-7_]+|b[01_]+)n?/))return i("number","number");if(/\d/.test(r))return e.match(/^[\d_]*(?:n|(?:\.[\d_]*)?(?:[eE][+\-]?[\d_]+)?)?/),i("number","number");if("/"==r)return e.eat("*")?(t.tokenize=K)(e,t):e.eat("/")?(e.skipToEnd(),i("comment","comment")):tt(e,t,1)?(function(e){for(var t,r=!1,n=!1;null!=(t=e.next());){if(!r){if("/"==t&&!n)return;"["==t?n=!0:n&&"]"==t&&(n=!1)}r=!r&&"\\"==t}}(e),e.match(/^\b(([gimyus])(?![gimyus]*\2))+\b/),i("regexp","string-2")):(e.eat("="),i("operator","operator",e.current()));if("`"==r)return(t.tokenize=L)(e,t);if("#"==r&&"!"==e.peek())return e.skipToEnd(),i("meta","meta");if("#"==r&&e.eatWhile(B))return i("variable","property");if("<"==r&&e.match("!--")||"-"==r&&e.match("->")&&!/\S/.test(e.string.slice(0,e.start)))return e.skipToEnd(),i("comment","comment");if(G.test(r))return">"==r&&t.lexical&&">"==t.lexical.type||(e.eat("=")?"!"!=r&&"="!=r||e.eat("="):/[<>*+\-|&?]/.test(r)&&(e.eat(r),">"==r&&e.eat(r))),"?"==r&&e.eat(".")?i("."):i("operator","operator",e.current());if(B.test(r)){e.eatWhile(B);r=e.current();if("."!=t.lastType){if(F.propertyIsEnumerable(r))return i((t=F[r]).type,t.style,r);if("async"==r&&e.match(/^(\s|\/\*([^*]|\*(?!\/))*?\*\/)*[\[\(\w]/,!1))return i("async","keyword",r)}return i("variable","variable",r)}}function K(e,t){for(var r,n=!1;r=e.next();){if("/"==r&&n){t.tokenize=d;break}n="*"==r}return i("comment","comment")}function L(e,t){for(var r,n=!1;null!=(r=e.next());){if(!n&&("`"==r||"$"==r&&e.eat("{"))){t.tokenize=d;break}n=!n&&"\\"==r}return i("quasi","string-2",e.current())}function Q(e,t){t.fatArrowAt&&(t.fatArrowAt=null);var r=e.string.indexOf("=>",e.start);if(!(r<0)){!u||(n=/:\s*(?:\w+(?:<[^>]*>|\[\])?|\{[^}]*\})\s*$/.exec(e.string.slice(e.start,r)))&&(r=n.index);for(var n,a=0,i=!1,o=r-1;0<=o;--o){var c=e.string.charAt(o),s="([{}])".indexOf(c);if(0<=s&&s<3){if(!a){++o;break}if(0==--a){"("==c&&(i=!0);break}}else if(3<=s&&s<6)++a;else if(B.test(c))i=!0;else if(/["'\/`]/.test(c))for(;;--o){if(0==o)return;if(e.string.charAt(o-1)==c&&"\\"!=e.string.charAt(o-2)){o--;break}}else if(i&&!a){++o;break}}i&&!a&&(t.fatArrowAt=o)}}var R={atom:!0,number:!0,variable:!0,string:!0,regexp:!0,this:!0,import:!0,"jsonld-keyword":!0};function X(e,t,r,n,a,i){this.indented=e,this.column=t,this.type=r,this.prev=a,this.info=i,null!=n&&(this.align=n)}function Y(e,t,r,n,a){var i=e.cc;for(c.state=e,c.stream=a,c.marked=null,c.cc=i,c.style=t,e.lexical.hasOwnProperty("align")||(e.lexical.align=!0);;)if((i.length?i.pop():o?x:b)(r,n)){for(;i.length&&i[i.length-1].lex;)i.pop()();return c.marked?c.marked:"variable"==r&&function(e,t){if(W){for(var r=e.localVars;r;r=r.next)if(r.name==t)return 1;for(var n=e.context;n;n=n.prev)for(r=n.vars;r;r=r.next)if(r.name==t)return 1}}(e,n)?"variable-2":t}}var c={state:null,column:null,marked:null,cc:null};function s(){for(var e=arguments.length-1;0<=e;e--)c.cc.push(arguments[e])}function p(){return s.apply(null,arguments),!0}function Z(e,t){for(var r=t;r;r=r.next)if(r.name==e)return 1}function a(e){var t=c.state;if(c.marked="def",W){if(t.context)if("var"==t.lexical.info&&t.context&&t.context.block){var r=function e(t,r){{var n;return r?r.block?(n=e(t,r.prev))?n==r.prev?r:new te(n,r.vars,!0):null:Z(t,r.vars)?r:new te(r.prev,new re(t,r.varLine truncated
|
||||
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(T){"use strict";function e(e){for(var t={},r=0;r<e.length;++r)t[e[r].toLowerCase()]=!0;return t}T.defineMode("css",function(e,t){var a,i,r=t.inline,n=(t.propertyKeywords||(t=T.resolveMode("text/css")),e.indentUnit),l=t.tokenHooks,o=t.documentTypes||{},s=t.mediaTypes||{},c=t.mediaFeatures||{},d=t.mediaValueKeywords||{},p=t.propertyKeywords||{},u=t.nonStandardPropertyKeywords||{},m=t.fontProperties||{},g=t.counterDescriptors||{},b=t.colorKeywords||{},h=t.valueKeywords||{},f=t.allowNested,k=t.lineComment,y=!0===t.supportsAtComponent,w=!1!==e.highlightNonStandardPropertyKeywords;function v(e,t){return a=t,e}function x(i){return function(e,t){for(var r,o=!1;null!=(r=e.next());){if(r==i&&!o){")"==i&&e.backUp(1);break}o=!o&&"\\"==r}return r!=i&&(o||")"==i)||(t.tokenize=null),a="string"}}function z(e,t){return e.next(),e.match(/^\s*[\"\')]/,!1)?t.tokenize=null:t.tokenize=x(")"),a="(",null}function j(e,t,r){this.type=e,this.indent=t,this.prev=r}function P(e,t,r,o){return e.context=new j(r,t.indentation()+(!1===o?0:n),e.context),r}function K(e){return e.context.prev&&(e.context=e.context.prev),e.context.type}function q(e,t,r){return _[r.context.type](e,t,r)}function C(e,t,r,o){for(var i=o||1;0<i;i--)r.context=r.context.prev;return q(e,t,r)}function B(e){e=e.current().toLowerCase();i=h.hasOwnProperty(e)?"atom":b.hasOwnProperty(e)?"keyword":"variable"}var _={top:function(e,t,r){if("{"==e)return P(r,t,"block");if("}"==e&&r.context.prev)return K(r);if(y&&/@component/i.test(e))return P(r,t,"atComponentBlock");if(/^@(-moz-)?document$/i.test(e))return P(r,t,"documentTypes");if(/^@(media|supports|(-moz-)?document|import)$/i.test(e))return P(r,t,"atBlock");if(/^@(font-face|counter-style)/i.test(e))return r.stateArg=e,"restricted_atBlock_before";if(/^@(-(moz|ms|o|webkit)-)?keyframes$/i.test(e))return"keyframes";if(e&&"@"==e.charAt(0))return P(r,t,"at");if("hash"==e)i="builtin";else if("word"==e)i="tag";else{if("variable-definition"==e)return"maybeprop";if("interpolation"==e)return P(r,t,"interpolation");if(":"==e)return"pseudo";if(f&&"("==e)return P(r,t,"parens")}return r.context.type},block:function(e,t,r){var o;return"word"==e?(o=t.current().toLowerCase(),p.hasOwnProperty(o)?(i="property","maybeprop"):u.hasOwnProperty(o)?(i=w?"string-2":"property","maybeprop"):f?(i=t.match(/^\s*:(?:\s|$)/,!1)?"property":"tag","block"):(i+=" error","maybeprop")):"meta"==e?"block":f||"hash"!=e&&"qualifier"!=e?_.top(e,t,r):(i="error","block")},maybeprop:function(e,t,r){return":"==e?P(r,t,"prop"):q(e,t,r)},prop:function(e,t,r){if(";"==e)return K(r);if("{"==e&&f)return P(r,t,"propBlock");if("}"==e||"{"==e)return C(e,t,r);if("("==e)return P(r,t,"parens");if("hash"!=e||/^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(t.current())){if("word"==e)B(t);else if("interpolation"==e)return P(r,t,"interpolation")}else i+=" error";return"prop"},propBlock:function(e,t,r){return"}"==e?K(r):"word"==e?(i="property","maybeprop"):r.context.type},parens:function(e,t,r){return"{"==e||"}"==e?C(e,t,r):")"==e?K(r):"("==e?P(r,t,"parens"):"interpolation"==e?P(r,t,"interpolation"):("word"==e&&B(t),"parens")},pseudo:function(e,t,r){return"meta"==e?"pseudo":"word"==e?(i="variable-3",r.context.type):q(e,t,r)},documentTypes:function(e,t,r){return"word"==e&&o.hasOwnProperty(t.current())?(i="tag",r.context.type):_.atBlock(e,t,r)},atBlock:function(e,t,r){return"("==e?P(r,t,"atBlock_parens"):"}"==e||";"==e?C(e,t,r):"{"==e?K(r)&&P(r,t,f?"block":"top"):"interpolation"==e?P(r,t,"interpolation"):("word"==e&&(e=t.current().toLowerCase(),i="only"==e||"not"==e||"and"==e||"or"==e?"keyword":s.hasOwnProperty(e)?"attribute":c.hasOwnProperty(e)?"property":d.hasOwnProperty(e)?"keyword":p.hasOwnProperty(e)?"property":u.hasOwnProperty(e)?w?"string-2":"property":h.hasOwnProperty(e)?"atom":b.hasOwnProperty(e)?"keyword":"error"),r.context.type)},atComponentBlock:function(e,t,r){return"}"==e?C(e,t,r):"{"==e?K(r)&&P(r,t,f?"block":"top",!1):("word"==e&&(i="error"),r.context.type)},atBlock_parens:function(e,t,r){return")"==e?K(r):"{"==e||"}"==e?C(e,t,r,2):_.atBlock(e,t,r)},restricted_atBlock_before:function(e,t,r){return"{"==e?P(r,t,"restricted_atBlock"):"word"==e&&"@counter-style"==r.stateArg?(i="variable","restricted_atBlock_before"):q(e,t,r)},restricted_atBlock:function(e,t,r){return"}"==e?(r.stateArg=null,K(r)):"word"==e?(i="@font-face"==r.stateArg&&!m.hasOwnProperty(t.current().toLowerCase())||"@counter-style"==r.stateArg&&!g.hasOwnProperty(t.current().toLowerCase())?"error":"property","maybeprop"):"restricted_atBlock"},keyframes:function(e,t,r){return"word"==e?(i="variable","keyframes"):"{"==e?P(r,t,"top"):q(e,t,r)},at:function(e,t,r){return";"==e?K(r):"{"==e||"}"==e?C(e,t,r):("word"==e?i="tag":"hash"==e&&(i="builtin"),"at")},interpolation:function(e,t,r){return"}"==e?K(r):"{"==e||";"==e?C(e,tLine truncated
|
||||
!function(t){"object"==typeof exports&&"object"==typeof module?t(require("../../lib/codemirror"),require("../xml/xml"),require("../javascript/javascript"),require("../css/css")):"function"==typeof define&&define.amd?define(["../../lib/codemirror","../xml/xml","../javascript/javascript","../css/css"],t):t(CodeMirror)}(function(m){"use strict";var l={script:[["lang",/(javascript|babel)/i,"javascript"],["type",/^(?:text|application)\/(?:x-)?(?:java|ecma)script$|^module$|^$/i,"javascript"],["type",/./,"text/plain"],[null,null,"javascript"]],style:[["lang",/^css$/i,"css"],["type",/^(text\/)?(x-)?(stylesheet|css)$/i,"css"],["type",/./,"text/plain"],[null,null,"css"]]};var a={};function d(t,e){e=t.match(a[t=e]||(a[t]=new RegExp("\\s+"+t+"\\s*=\\s*('|\")?([^'\"]+)('|\")?\\s*")));return e?/^\s*(.*?)\s*$/.exec(e[2])[1]:""}function g(t,e){return new RegExp((e?"^":"")+"</\\s*"+t+"\\s*>","i")}function o(t,e){for(var a in t)for(var n=e[a]||(e[a]=[]),l=t[a],o=l.length-1;0<=o;o--)n.unshift(l[o])}m.defineMode("htmlmixed",function(i,t){var c=m.getMode(i,{name:"xml",htmlMode:!0,multilineTagIndentFactor:t.multilineTagIndentFactor,multilineTagIndentPastTag:t.multilineTagIndentPastTag,allowMissingTagName:t.allowMissingTagName}),s={},e=t&&t.tags,a=t&&t.scriptTypes;if(o(l,s),e&&o(e,s),a)for(var n=a.length-1;0<=n;n--)s.script.unshift(["type",a[n].matches,a[n].mode]);function u(t,e){var a,o,r,n=c.token(t,e.htmlState),l=/\btag\b/.test(n);return l&&!/[<>\s\/]/.test(t.current())&&(a=e.htmlState.tagName&&e.htmlState.tagName.toLowerCase())&&s.hasOwnProperty(a)?e.inTag=a+" ":e.inTag&&l&&/>$/.test(t.current())?(a=/^([\S]+) (.*)/.exec(e.inTag),e.inTag=null,l=">"==t.current()&&function(t,e){for(var a=0;a<t.length;a++){var n=t[a];if(!n[0]||n[1].test(d(e,n[0])))return n[2]}}(s[a[1]],a[2]),l=m.getMode(i,l),o=g(a[1],!0),r=g(a[1],!1),e.token=function(t,e){return t.match(o,!1)?(e.token=u,e.localState=e.localMode=null):(a=t,n=r,t=e.localMode.token(t,e.localState),e=a.current(),-1<(l=e.search(n))?a.backUp(e.length-l):e.match(/<\/?$/)&&(a.backUp(e.length),a.match(n,!1)||a.match(e)),t);var a,n,l},e.localMode=l,e.localState=m.startState(l,c.indent(e.htmlState,"",""))):e.inTag&&(e.inTag+=t.current(),t.eol()&&(e.inTag+=" ")),n}return{startState:function(){return{token:u,inTag:null,localMode:null,localState:null,htmlState:m.startState(c)}},copyState:function(t){var e;return t.localState&&(e=m.copyState(t.localMode,t.localState)),{token:t.token,inTag:t.inTag,localMode:t.localMode,localState:e,htmlState:m.copyState(c,t.htmlState)}},token:function(t,e){return e.token(t,e)},indent:function(t,e,a){return!t.localMode||/^\s*<\//.test(e)?c.indent(t.htmlState,e,a):t.localMode.indent?t.localMode.indent(t.localState,e,a):m.Pass},innerMode:function(t){return{state:t.localState||t.htmlState,mode:t.localMode||c}}}},"xml","javascript","css"),m.defineMIME("text/html","htmlmixed")});;
|
||||
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(M){"use strict";function L(e,t,n,r,o,a){this.indented=e,this.column=t,this.type=n,this.info=r,this.align=o,this.prev=a}function D(e,t,n,r){var o=e.indented;e.context&&"statement"==e.context.type&&"statement"!=n&&(o=e.context.indented),e.context=new L(o,t,n,r,null,e.context)}function P(e){var t=e.context.type;return")"!=t&&"]"!=t&&"}"!=t||(e.indented=e.context.indented),e.context=e.context.prev}function E(e,t,n){return"variable"==t.prevToken||"type"==t.prevToken||(!!/\S(?:[^- ]>|[*\]])\s*$|\*$/.test(e.string.slice(0,n))||(!(!t.typeAtEndOfLine||e.column()!=e.indentation())||void 0))}function z(e){for(;;){if(!e||"top"==e.type)return 1;if("}"==e.type&&"namespace"!=e.prev.info)return;e=e.prev}}function e(e){for(var t={},n=e.split(" "),r=0;r<n.length;++r)t[n[r]]=!0;return t}function F(e,t){return"function"==typeof e?e(t):e.propertyIsEnumerable(t)}M.defineMode("clike",function(e,a){var i,l,s=e.indentUnit,c=a.statementIndentUnit||s,u=a.dontAlignCalls,o=a.keywords||{},d=a.types||{},f=a.builtin||{},p=a.blockKeywords||{},m=a.defKeywords||{},h=a.atoms||{},y=a.hooks||{},g=a.multiLineStrings,k=!1!==a.indentStatements,e=!1!==a.indentSwitch,x=a.namespaceSeparator,b=a.isPunctuationChar||/[\[\]{}\(\),;\:\.]/,v=a.numberStart||/[\d\.]/,w=a.number||/^(?:0x[a-f\d]+|0b[01]+|(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?)(u|ll?|l|f)?/i,_=a.isOperatorChar||/[+\-*&%=<>!?|\/]/,S=a.isIdentifierChar||/[\w\$_\xa1-\uffff]/,T=a.isReservedIdentifier||!1;function C(e,t){var a,n=e.next();if(y[n]){var r=y[n](e,t);if(!1!==r)return r}if('"'==n||"'"==n)return t.tokenize=(a=n,function(e,t){for(var n,r=!1,o=!1;null!=(n=e.next());){if(n==a&&!r){o=!0;break}r=!r&&"\\"==n}return!o&&(r||g)||(t.tokenize=null),"string"}),t.tokenize(e,t);if(v.test(n)){if(e.backUp(1),e.match(w))return"number";e.next()}if(b.test(n))return i=n,null;if("/"==n){if(e.eat("*"))return(t.tokenize=I)(e,t);if(e.eat("/"))return e.skipToEnd(),"comment"}if(_.test(n)){for(;!e.match(/^\/[\/*]/,!1)&&e.eat(_););return"operator"}if(e.eatWhile(S),x)for(;e.match(x);)e.eatWhile(S);r=e.current();return F(o,r)?(F(p,r)&&(i="newstatement"),F(m,r)&&(l=!0),"keyword"):F(d,r)?"type":F(f,r)||T&&T(r)?(F(p,r)&&(i="newstatement"),"builtin"):F(h,r)?"atom":"variable"}function I(e,t){for(var n,r=!1;n=e.next();){if("/"==n&&r){t.tokenize=null;break}r="*"==n}return"comment"}function N(e,t){a.typeFirstDefinitions&&e.eol()&&z(t.context)&&(t.typeAtEndOfLine=E(e,t,e.pos))}return{startState:function(e){return{tokenize:null,context:new L((e||0)-s,0,"top",null,!1),indented:0,startOfLine:!0,prevToken:null}},token:function(e,t){var n=t.context;if(e.sol()&&(null==n.align&&(n.align=!1),t.indented=e.indentation(),t.startOfLine=!0),e.eatSpace())return N(e,t),null;i=l=null;var r,o=(t.tokenize||C)(e,t);if("comment"==o||"meta"==o)return o;if(null==n.align&&(n.align=!0),";"==i||":"==i||","==i&&e.match(/^\s*(?:\/\/.*)?$/,!1))for(;"statement"==t.context.type;)P(t);else if("{"==i)D(t,e.column(),"}");else if("["==i)D(t,e.column(),"]");else if("("==i)D(t,e.column(),")");else if("}"==i){for(;"statement"==n.type;)n=P(t);for("}"==n.type&&(n=P(t));"statement"==n.type;)n=P(t)}else i==n.type?P(t):k&&(("}"==n.type||"top"==n.type)&&";"!=i||"statement"==n.type&&"newstatement"==i)&&D(t,e.column(),"statement",e.current());return"variable"==o&&("def"==t.prevToken||a.typeFirstDefinitions&&E(e,t,e.start)&&z(t.context)&&e.match(/^\s*\(/,!1))&&(o="def"),"def"==(o=y.token&&void 0!==(r=y.token(e,t,o))?r:o)&&!1===a.styleDefs&&(o="variable"),t.startOfLine=!1,t.prevToken=l?"def":o||i,N(e,t),o},indent:function(e,t){if(e.tokenize!=C&&null!=e.tokenize||e.typeAtEndOfLine&&z(e.context))return M.Pass;var n=e.context,r=t&&t.charAt(0),o=r==n.type;if("statement"==n.type&&"}"==r&&(n=n.prev),a.dontIndentStatements)for(;"statement"==n.type&&a.dontIndentStatements.test(n.info);)n=n.prev;if(y.indent){e=y.indent(e,n,t,s);if("number"==typeof e)return e}e=n.prev&&"switch"==n.prev.info;if(a.allmanIndentation&&/[{(]/.test(r)){for(;"top"!=n.type&&"}"!=n.type;)n=n.prev;return n.indented}return"statement"==n.type?n.indented+("{"==r?0:c):!n.align||u&&")"==n.type?")"!=n.type||o?n.indented+(o?0:s)+(o||!e||/^(?:case|default)\b/.test(t)?0:s):n.indented+c:n.column+(o?0:1)},electricInput:e?/^\s*(?:case .*?:|default:|\{\}?|\})$/:/^\s*[{}]$/,blockCommentStart:"/*",blockCommentEnd:"*/",blockCommentContinue:" * ",lineComment:"//",fold:"brace"}});var t="auto if break case register continue return default do sizeof static else struct switch extern typedef union for goto while enum const volatile inline restrict asm fortran",n="alignas alignof and and_eq audit axiom bitand bitor catch class compl concept constexpr const_cast decltype delete dynamic_cast explicit export final friend import module mutable namespace new noexcept not not_eq operator or or_eq override private protected public reinterpret_cast requLine truncated
|
||||
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror"),require("../htmlmixed/htmlmixed"),require("../clike/clike")):"function"==typeof define&&define.amd?define(["../../lib/codemirror","../htmlmixed/htmlmixed","../clike/clike"],e):e(CodeMirror)}(function(a){"use strict";function e(e){for(var t={},_=e.split(" "),r=0;r<_.length;++r)t[_[r]]=!0;return t}function o(s,i,l){return 0==s.length?n(i):function(e,t){for(var _=s[0],r=0;r<_.length;r++)if(e.match(_[r][0]))return t.tokenize=o(s.slice(1),i),_[r][1];return t.tokenize=n(i,l),"string"}}function n(n,a){return function(e,t){var _=e,r=t,s=n,i=a;if(!1!==i&&_.match("${",!1)||_.match("{$",!1))return r.tokenize=null,"string";if(!1!==i&&_.match(/^\$[a-zA-Z_][a-zA-Z0-9_]*/))return _.match("[",!1)&&(r.tokenize=o([[["[",null]],[[/\d[\w\.]*/,"number"],[/\$[a-zA-Z_][a-zA-Z0-9_]*/,"variable-2"],[/[\w\$]+/,"variable"]],[["]",null]]],s,i)),_.match(/^->\w/,!1)&&(r.tokenize=o([[["->",null]],[[/[\w]+/,"variable"]]],s,i)),"variable-2";for(var l=!1;!_.eol()&&(l||!1===i||!_.match("{$",!1)&&!_.match(/^(\$[a-zA-Z_][a-zA-Z0-9_]*|\$\{)/,!1));){if(!l&&_.match(s)){r.tokenize=null,r.tokStack.pop(),r.tokStack.pop();break}l="\\"==_.next()&&!l}return"string"}}var t="abstract and array as break case catch class clone const continue declare default do else elseif enddeclare endfor endforeach endif endswitch endwhile enum extends final for foreach function global goto if implements interface instanceof namespace new or private protected public static switch throw trait try use var while xor die echo empty exit eval include include_once isset list require require_once return print unset __halt_compiler self static parent yield insteadof finally readonly match",_="true false null TRUE FALSE NULL __CLASS__ __DIR__ __FILE__ __LINE__ __METHOD__ __FUNCTION__ __NAMESPACE__ __TRAIT__",r="func_num_args func_get_arg func_get_args strlen strcmp strncmp strcasecmp strncasecmp each error_reporting define defined trigger_error user_error set_error_handler restore_error_handler get_declared_classes get_loaded_extensions extension_loaded get_extension_funcs debug_backtrace constant bin2hex hex2bin sleep usleep time mktime gmmktime strftime gmstrftime strtotime date gmdate getdate localtime checkdate flush wordwrap htmlspecialchars htmlentities html_entity_decode md5 md5_file crc32 getimagesize image_type_to_mime_type phpinfo phpversion phpcredits strnatcmp strnatcasecmp substr_count strspn strcspn strtok strtoupper strtolower strpos strrpos strrev hebrev hebrevc nl2br basename dirname pathinfo stripslashes stripcslashes strstr stristr strrchr str_shuffle str_word_count strcoll substr substr_replace quotemeta ucfirst ucwords strtr addslashes addcslashes rtrim str_replace str_repeat count_chars chunk_split trim ltrim strip_tags similar_text explode implode setlocale localeconv parse_str str_pad chop strchr sprintf printf vprintf vsprintf sscanf fscanf parse_url urlencode urldecode rawurlencode rawurldecode readlink linkinfo link unlink exec system escapeshellcmd escapeshellarg passthru shell_exec proc_open proc_close rand srand getrandmax mt_rand mt_srand mt_getrandmax base64_decode base64_encode abs ceil floor round is_finite is_nan is_infinite bindec hexdec octdec decbin decoct dechex base_convert number_format fmod ip2long long2ip getenv putenv getopt microtime gettimeofday getrusage uniqid quoted_printable_decode set_time_limit get_cfg_var magic_quotes_runtime set_magic_quotes_runtime get_magic_quotes_gpc get_magic_quotes_runtime import_request_variables error_log serialize unserialize memory_get_usage memory_get_peak_usage var_dump var_export debug_zval_dump print_r highlight_file show_source highlight_string ini_get ini_get_all ini_set ini_alter ini_restore get_include_path set_include_path restore_include_path setcookie header headers_sent connection_aborted connection_status ignore_user_abort parse_ini_file is_uploaded_file move_uploaded_file intval floatval doubleval strval gettype settype is_null is_resource is_bool is_long is_float is_int is_integer is_double is_real is_numeric is_string is_array is_object is_scalar ereg ereg_replace eregi eregi_replace split spliti join sql_regcase dl pclose popen readfile rewind rmdir umask fclose feof fgetc fgets fgetss fread fopen fpassthru ftruncate fstat fseek ftell fflush fwrite fputs mkdir rename copy tempnam tmpfile file file_get_contents file_put_contents stream_select stream_context_create stream_context_set_params stream_context_set_option stream_context_get_options stream_filter_prepend stream_filter_append fgetcsv flock get_meta_tags stream_set_write_buffer set_file_buffer set_socket_blocking stream_set_blocking socket_set_blocking stream_get_meta_data stream_register_wrapper stream_wrapper_register stream_set_timeout socket_set_timeout socket_get_status realpath fnmatch fsockopen pfsockopen pack unpack get_browser crypt opendir closedir chdir getcwd rewinddir readdir dir glob fileatime filectime filegroup fileiLine truncated
|
||||
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(x){"use strict";function k(e){return new RegExp("^(("+e.join(")|(")+"))\\b")}var _=k(["and","or","not","is"]),v=["as","assert","break","class","continue","def","del","elif","else","except","finally","for","from","global","if","import","lambda","pass","raise","return","try","while","with","yield","in","False","True"],z=["abs","all","any","bin","bool","bytearray","callable","chr","classmethod","compile","complex","delattr","dict","dir","divmod","enumerate","eval","filter","float","format","frozenset","getattr","globals","hasattr","hash","help","hex","id","input","int","isinstance","issubclass","iter","len","list","locals","map","max","memoryview","min","next","object","oct","open","ord","pow","property","range","repr","reversed","round","set","setattr","slice","sorted","staticmethod","str","sum","super","tuple","type","vars","zip","__import__","NotImplemented","Ellipsis","__debug__"];function w(e){return e.scopes[e.scopes.length-1]}x.registerHelper("hintWords","python",v.concat(z).concat(["exec","print"])),x.defineMode("python",function(t,l){for(var s="error",o=l.delimiters||l.singleDelimiters||/^[\(\)\[\]\{\}@,:`=;\.\\]/,a=[l.singleOperators,l.doubleOperators,l.doubleDelimiters,l.tripleDelimiters,l.operators||/^([-+*/%\/&|^]=?|[<>=]+|\/\/=?|\*\*=?|!=|[~!@]|\.\.\.)/],e=0;e<a.length;e++)a[e]||a.splice(e--,1);var c,u,f=l.hangingIndent||t.indentUnit,n=v,r=z,p=(null!=l.extra_keywords&&(n=n.concat(l.extra_keywords)),null!=l.extra_builtins&&(r=r.concat(l.extra_builtins)),!(l.version&&Number(l.version)<3)),d=(u=p?(c=l.identifiers||/^[_A-Za-z\u00A1-\uFFFF][_A-Za-z0-9\u00A1-\uFFFF]*/,n=n.concat(["nonlocal","None","aiter","anext","async","await","breakpoint","match","case"]),r=r.concat(["ascii","bytes","exec","print"]),new RegExp("^(([rbuf]|(br)|(rb)|(fr)|(rf))?('{3}|\"{3}|['\"]))","i")):(c=l.identifiers||/^[_A-Za-z][_A-Za-z0-9]*/,n=n.concat(["exec","print"]),r=r.concat(["apply","basestring","buffer","cmp","coerce","execfile","file","intern","long","raw_input","reduce","reload","unichr","unicode","xrange","None"]),new RegExp("^(([rubf]|(ur)|(br))?('{3}|\"{3}|['\"]))","i")),k(n)),m=k(r);function i(e,t){var n,r=e.sol()&&"\\"!=t.lastToken;return r&&(t.indent=e.indentation()),r&&"py"==w(t).type?(r=w(t).offset,e.eatSpace()?(r<(n=e.indentation())?b(t):n<r&&y(e,t)&&"#"!=e.peek()&&(t.errorToken=!0),null):(n=h(e,t),0<r&&y(e,t)&&(n+=" "+s),n)):h(e,t)}function h(e,t,n){if(e.eatSpace())return null;if(!n&&e.match(/^#.*/))return"comment";if(e.match(/^[0-9\.]/,!1)){var r=!1;if(e.match(/^[\d_]*\.\d+(e[\+\-]?\d+)?/i)&&(r=!0),e.match(/^[\d_]+\.\d*/)&&(r=!0),r=e.match(/^\.\d+/)?!0:r)return e.eat(/J/i),"number";r=!1;if(e.match(/^0x[0-9a-f_]+/i)&&(r=!0),e.match(/^0b[01_]+/i)&&(r=!0),e.match(/^0o[0-7_]+/i)&&(r=!0),e.match(/^[1-9][\d_]*(e[\+\-]?[\d_]+)?/)&&(e.eat(/J/i),r=!0),r=e.match(/^0(?![\dx])/i)?!0:r)return e.eat(/L/i),"number"}if(e.match(u))return r=-1!==e.current().toLowerCase().indexOf("f"),t.tokenize=(r?function(n,r){for(;0<="rubf".indexOf(n.charAt(0).toLowerCase());)n=n.substr(1);var i=1==n.length,o="string";function a(e,t){for(;!e.eol();)if(e.eatWhile(/[^'"\{\}\\]/),e.eat("\\")){if(e.next(),i&&e.eol())return o}else{if(e.match(n))return t.tokenize=r,o;if(e.match("{{"))return o;if(e.match("{",!1))return t.tokenize=function r(i){return function(e,t){var n=h(e,t,!0);return"punctuation"==n&&("{"==e.current()?t.tokenize=r(i+1):"}"==e.current()&&(t.tokenize=1<i?r(i-1):a)),n}}(0),e.current()?o:t.tokenize(e,t);if(e.match("}}"))return o;if(e.match("}"))return s;e.eat(/['"]/)}if(i){if(l.singleLineStringErrors)return s;t.tokenize=r}return o}return a.isString=!0,a}:function(n,r){for(;0<="rubf".indexOf(n.charAt(0).toLowerCase());)n=n.substr(1);var i=1==n.length,o="string";function e(e,t){for(;!e.eol();)if(e.eatWhile(/[^'"\\]/),e.eat("\\")){if(e.next(),i&&e.eol())return o}else{if(e.match(n))return t.tokenize=r,o;e.eat(/['"]/)}if(i){if(l.singleLineStringErrors)return s;t.tokenize=r}return o}return e.isString=!0,e})(e.current(),t.tokenize),t.tokenize(e,t);for(var i=0;i<a.length;i++)if(e.match(a[i]))return"operator";return e.match(o)?"punctuation":"."==t.lastToken&&e.match(c)?"property":e.match(d)||e.match(_)?"keyword":e.match(m)?"builtin":e.match(/^(self|cls)\b/)?"variable-2":e.match(c)?"def"==t.lastToken||"class"==t.lastToken?"def":"variable":(e.next(),n?null:s)}function b(e){for(;"py"!=w(e).type;)e.scopes.pop();e.scopes.push({offset:w(e).offset+t.indentUnit,type:"py",align:null})}function y(e,t){for(var n=e.indentation();1<t.scopes.length&&w(t).offset>n;){if("py"!=w(t).type)return 1;t.scopes.pop()}return w(t).offset!=n}function g(e,t){e.sol()&&(t.beginningOfLine=!0,t.dedent=!1);var n,r,i,o=t.tokenize(e,t),a=e.current();if(t.beginningOfLine&&"@"==a)return e.match(c,!1)?"meta":p?"operator":s;if(/\S/.test(a)&&(t.beginningOfLine=!1),"variable"!=o&&"builtin"Line truncated
|
||||
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(p){"use strict";p.defineMode("go",function(e){var i,o=e.indentUnit,r={break:!0,case:!0,chan:!0,const:!0,continue:!0,default:!0,defer:!0,else:!0,fallthrough:!0,for:!0,func:!0,go:!0,goto:!0,if:!0,import:!0,interface:!0,map:!0,package:!0,range:!0,return:!0,select:!0,struct:!0,switch:!0,type:!0,var:!0,bool:!0,byte:!0,complex64:!0,complex128:!0,float32:!0,float64:!0,int8:!0,int16:!0,int32:!0,int64:!0,string:!0,uint8:!0,uint16:!0,uint32:!0,uint64:!0,int:!0,uint:!0,uintptr:!0,error:!0,rune:!0,any:!0,comparable:!0},a={true:!0,false:!0,iota:!0,nil:!0,append:!0,cap:!0,close:!0,complex:!0,copy:!0,delete:!0,imag:!0,len:!0,make:!0,new:!0,panic:!0,print:!0,println:!0,real:!0,recover:!0},c=/[+\-*&^%:=<>!|\/]/;function u(e,t){var o,n=e.next();if('"'==n||"'"==n||"`"==n)return t.tokenize=(o=n,function(e,t){for(var n,r=!1,i=!1;null!=(n=e.next());){if(n==o&&!r){i=!0;break}r=!r&&"`"!=o&&"\\"==n}return(i||!r&&"`"!=o)&&(t.tokenize=u),"string"}),t.tokenize(e,t);if(/[\d\.]/.test(n))return"."==n?e.match(/^[0-9_]+([eE][\-+]?[0-9_]+)?/):"0"==n?e.match(/^[xX][0-9a-fA-F_]+/)||e.match(/^[0-7_]+/):e.match(/^[0-9_]*\.?[0-9_]*([eE][\-+]?[0-9_]+)?/),"number";if(/[\[\]{}\(\),;\:\.]/.test(n))return i=n,null;if("/"==n){if(e.eat("*"))return(t.tokenize=l)(e,t);if(e.eat("/"))return e.skipToEnd(),"comment"}if(c.test(n))return e.eatWhile(c),"operator";e.eatWhile(/[\w\$_\xa1-\uffff]/);t=e.current();return r.propertyIsEnumerable(t)?("case"!=t&&"default"!=t||(i="case"),"keyword"):a.propertyIsEnumerable(t)?"atom":"variable"}function l(e,t){for(var n,r=!1;n=e.next();){if("/"==n&&r){t.tokenize=u;break}r="*"==n}return"comment"}function f(e,t,n,r,i){this.indented=e,this.column=t,this.type=n,this.align=r,this.prev=i}function s(e,t,n){e.context=new f(e.indented,t,n,null,e.context)}function d(e){var t;e.context.prev&&(")"!=(t=e.context.type)&&"]"!=t&&"}"!=t||(e.indented=e.context.indented),e.context=e.context.prev)}return{startState:function(e){return{tokenize:null,context:new f((e||0)-o,0,"top",!1),indented:0,startOfLine:!0}},token:function(e,t){var n=t.context;if(e.sol()&&(null==n.align&&(n.align=!1),t.indented=e.indentation(),t.startOfLine=!0,"case"==n.type&&(n.type="}")),e.eatSpace())return null;i=null;var r=(t.tokenize||u)(e,t);return"comment"==r||(null==n.align&&(n.align=!0),"{"==i?s(t,e.column(),"}"):"["==i?s(t,e.column(),"]"):"("==i?s(t,e.column(),")"):"case"==i?n.type="case":("}"==i&&"}"==n.type||i==n.type)&&d(t),t.startOfLine=!1),r},indent:function(e,t){if(e.tokenize!=u&&null!=e.tokenize)return p.Pass;var n=e.context,r=t&&t.charAt(0);if("case"==n.type&&/^(?:case|default)\b/.test(t))return e.context.type="}",n.indented;t=r==n.type;return n.align?n.column+(t?0:1):n.indented+(t?0:o)},electricChars:"{}):",closeBrackets:"()[]{}''\"\"``",fold:"brace",blockCommentStart:"/*",blockCommentEnd:"*/",lineComment:"//"}}),p.defineMIME("text/x-go","go")});;
|
||||
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(e){"use strict";e.defineMode("yaml",function(){var n=new RegExp("\\b(("+["true","false","on","off","yes","no"].join(")|(")+"))$","i");return{token:function(e,i){var t=e.peek(),r=i.escaped;if(i.escaped=!1,"#"==t&&(0==e.pos||/\s/.test(e.string.charAt(e.pos-1))))return e.skipToEnd(),"comment";if(e.match(/^('([^']|\\.)*'?|"([^"]|\\.)*"?)/))return"string";if(i.literal&&e.indentation()>i.keyCol)return e.skipToEnd(),"string";if(i.literal&&(i.literal=!1),e.sol()){if(i.keyCol=0,i.pair=!1,i.pairStart=!1,e.match("---"))return"def";if(e.match("..."))return"def";if(e.match(/\s*-\s+/))return"meta"}if(e.match(/^(\{|\}|\[|\])/))return"{"==t?i.inlinePairs++:"}"==t?i.inlinePairs--:"["==t?i.inlineList++:i.inlineList--,"meta";if(0<i.inlineList&&!r&&","==t)return e.next(),"meta";if(0<i.inlinePairs&&!r&&","==t)return i.keyCol=0,i.pair=!1,i.pairStart=!1,e.next(),"meta";if(i.pairStart){if(e.match(/^\s*(\||\>)\s*/))return i.literal=!0,"meta";if(e.match(/^\s*(\&|\*)[a-z0-9\._-]+\b/i))return"variable-2";if(0==i.inlinePairs&&e.match(/^\s*-?[0-9\.\,]+\s?$/))return"number";if(0<i.inlinePairs&&e.match(/^\s*-?[0-9\.\,]+\s?(?=(,|}))/))return"number";if(e.match(n))return"keyword"}return!i.pair&&e.match(/^\s*(?:[,\[\]{}&*!|>'"%@`][^\s'":]|[^\s,\[\]{}#&*!|>'"%@`])[^#:]*(?=:($|\s))/)?(i.pair=!0,i.keyCol=e.indentation(),"atom"):i.pair&&e.match(/^:\s*/)?(i.pairStart=!0,"meta"):(i.pairStart=!1,i.escaped="\\"==t,e.next(),null)},startState:function(){return{pair:!1,pairStart:!1,keyCol:0,inlinePairs:0,inlineList:0,literal:!1,escaped:!1}},lineComment:"#",fold:"indent"}}),e.defineMIME("text/x-yaml","yaml"),e.defineMIME("text/yaml","yaml")});;
|
||||
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(s){"use strict";s.defineMode("shell",function(){var o={};function e(e,t){for(var n=0;n<t.length;n++)o[t[n]]=e}var t=["true","false"],n=["if","then","do","else","elif","while","until","for","in","esac","fi","fin","fil","done","exit","set","unset","export","function"],r=["ab","awk","bash","beep","cat","cc","cd","chown","chmod","chroot","clear","cp","curl","cut","diff","echo","find","gawk","gcc","get","git","grep","hg","kill","killall","ln","ls","make","mkdir","openssl","mv","nc","nl","node","npm","ping","ps","restart","rm","rmdir","sed","service","sh","shopt","shred","source","sort","sleep","ssh","start","stop","su","sudo","svn","tee","telnet","top","touch","vi","vim","wall","wc","wget","who","write","yes","zsh"];function i(e,t){if(e.eatSpace())return null;var n,r=e.sol(),i=e.next();if("\\"===i)return e.next(),null;if("'"===i||'"'===i||"`"===i)return t.tokens.unshift(f(i,"`"===i?"quote":"string")),l(e,t);if("#"===i)return r&&e.eat("!")?(e.skipToEnd(),"meta"):(e.skipToEnd(),"comment");if("$"===i)return t.tokens.unshift(u),l(e,t);if("+"===i||"="===i)return"operator";if("-"===i)return e.eat("-"),e.eatWhile(/\w/),"attribute";if("<"==i){if(e.match("<<"))return"operator";r=e.match(/^<-?\s*['"]?([^'"]*)['"]?/);if(r)return t.tokens.unshift((n=r[1],function(e,t){return e.sol()&&e.string==n&&t.tokens.shift(),e.skipToEnd(),"string-2"})),"string-2"}if(/\d/.test(i)&&(e.eatWhile(/\d/),e.eol()||!/\w/.test(e.peek())))return"number";e.eatWhile(/[\w-]/);t=e.current();return"="===e.peek()&&/\w+/.test(t)?"def":o.hasOwnProperty(t)?o[t]:null}function f(i,o){var s="("==i?")":"{"==i?"}":i;return function(e,t){for(var n,r=!1;null!=(n=e.next());){if(n===s&&!r){t.tokens.shift();break}if("$"===n&&!r&&"'"!==i&&e.peek()!=s){r=!0,e.backUp(1),t.tokens.unshift(u);break}if(!r&&i!==s&&n===i)return t.tokens.unshift(f(i,o)),l(e,t);if(!r&&/['"]/.test(n)&&!/['"]/.test(i)){t.tokens.unshift(function(n,r){return function(e,t){return t.tokens[0]=f(n,r),e.next(),l(e,t)}}(n,"string")),e.backUp(1);break}r=!r&&"\\"===n}return o}}s.registerHelper("hintWords","shell",t.concat(n,r)),e("atom",t),e("keyword",n),e("builtin",r);var u=function(e,t){1<t.tokens.length&&e.eat("$");var n=e.next();return/['"({]/.test(n)?(t.tokens[0]=f(n,"("==n?"quote":"{"==n?"def":"string"),l(e,t)):(/\d/.test(n)||e.eatWhile(/\w/),t.tokens.shift(),"def")};function l(e,t){return(t.tokens[0]||i)(e,t)}return{startState:function(){return{tokens:[]}},token:l,closeBrackets:"()[]{}''\"\"``",lineComment:"#",fold:"brace"}}),s.defineMIME("text/x-sh","shell"),s.defineMIME("application/x-sh","shell")});;
|
||||
!function(t){"object"==typeof exports&&"object"==typeof module?t(require("../../lib/codemirror"),require("../xml/xml"),require("../meta")):"function"==typeof define&&define.amd?define(["../../lib/codemirror","../xml/xml","../meta"],t):t(CodeMirror)}(function(w){"use strict";w.defineMode("markdown",function(m,d){var g=w.getMode(m,"text/html"),u="null"==g.name;void 0===d.highlightFormatting&&(d.highlightFormatting=!1),void 0===d.maxBlockquoteDepth&&(d.maxBlockquoteDepth=0),void 0===d.taskLists&&(d.taskLists=!1),void 0===d.strikethrough&&(d.strikethrough=!1),void 0===d.emoji&&(d.emoji=!1),void 0===d.fencedCodeBlockHighlighting&&(d.fencedCodeBlockHighlighting=!0),void 0===d.fencedCodeBlockDefaultMode&&(d.fencedCodeBlockDefaultMode="text/plain"),void 0===d.xml&&(d.xml=!0),void 0===d.tokenTypeOverrides&&(d.tokenTypeOverrides={});var t,c={header:"header",code:"comment",quote:"quote",list1:"variable-2",list2:"variable-3",list3:"keyword",hr:"hr",image:"image",imageAltText:"image-alt-text",imageMarker:"image-marker",formatting:"formatting",linkInline:"link",linkEmail:"link",linkText:"link",linkHref:"string",em:"em",strong:"strong",strikethrough:"strikethrough",emoji:"builtin"};for(t in c)c.hasOwnProperty(t)&&d.tokenTypeOverrides[t]&&(c[t]=d.tokenTypeOverrides[t]);var f=/^([*\-_])(?:\s*\1){2,}\s*$/,k=/^(?:[*\-+]|^[0-9]+([.)]))\s+/,F=/^\[(x| )\](?=\s)/i,D=d.allowAtxHeaderWithoutSpace?/^(#+)/:/^(#+)(?: |$)/,p=/^ {0,3}(?:\={1,}|-{2,})\s*$/,i=/^[^#!\[\]*_\\<>` "'(~:]+/,E=/^(~~~+|```+)[ \t]*([\w\/+#-]*)[^\n`]*$/,x=/^\s*\[[^\]]+?\]:.*$/,A=/[!"#$%&'()*+,\-.\/:;<=>?@\[\\\]^_`{|}~\xA1\xA7\xAB\xB6\xB7\xBB\xBF\u037E\u0387\u055A-\u055F\u0589\u058A\u05BE\u05C0\u05C3\u05C6\u05F3\u05F4\u0609\u060A\u060C\u060D\u061B\u061E\u061F\u066A-\u066D\u06D4\u0700-\u070D\u07F7-\u07F9\u0830-\u083E\u085E\u0964\u0965\u0970\u0AF0\u0DF4\u0E4F\u0E5A\u0E5B\u0F04-\u0F12\u0F14\u0F3A-\u0F3D\u0F85\u0FD0-\u0FD4\u0FD9\u0FDA\u104A-\u104F\u10FB\u1360-\u1368\u1400\u166D\u166E\u169B\u169C\u16EB-\u16ED\u1735\u1736\u17D4-\u17D6\u17D8-\u17DA\u1800-\u180A\u1944\u1945\u1A1E\u1A1F\u1AA0-\u1AA6\u1AA8-\u1AAD\u1B5A-\u1B60\u1BFC-\u1BFF\u1C3B-\u1C3F\u1C7E\u1C7F\u1CC0-\u1CC7\u1CD3\u2010-\u2027\u2030-\u2043\u2045-\u2051\u2053-\u205E\u207D\u207E\u208D\u208E\u2308-\u230B\u2329\u232A\u2768-\u2775\u27C5\u27C6\u27E6-\u27EF\u2983-\u2998\u29D8-\u29DB\u29FC\u29FD\u2CF9-\u2CFC\u2CFE\u2CFF\u2D70\u2E00-\u2E2E\u2E30-\u2E42\u3001-\u3003\u3008-\u3011\u3014-\u301F\u3030\u303D\u30A0\u30FB\uA4FE\uA4FF\uA60D-\uA60F\uA673\uA67E\uA6F2-\uA6F7\uA874-\uA877\uA8CE\uA8CF\uA8F8-\uA8FA\uA8FC\uA92E\uA92F\uA95F\uA9C1-\uA9CD\uA9DE\uA9DF\uAA5C-\uAA5F\uAADE\uAADF\uAAF0\uAAF1\uABEB\uFD3E\uFD3F\uFE10-\uFE19\uFE30-\uFE52\uFE54-\uFE61\uFE63\uFE68\uFE6A\uFE6B\uFF01-\uFF03\uFF05-\uFF0A\uFF0C-\uFF0F\uFF1A\uFF1B\uFF1F\uFF20\uFF3B-\uFF3D\uFF3F\uFF5B\uFF5D\uFF5F-\uFF65]|\uD800[\uDD00-\uDD02\uDF9F\uDFD0]|\uD801\uDD6F|\uD802[\uDC57\uDD1F\uDD3F\uDE50-\uDE58\uDE7F\uDEF0-\uDEF6\uDF39-\uDF3F\uDF99-\uDF9C]|\uD804[\uDC47-\uDC4D\uDCBB\uDCBC\uDCBE-\uDCC1\uDD40-\uDD43\uDD74\uDD75\uDDC5-\uDDC9\uDDCD\uDDDB\uDDDD-\uDDDF\uDE38-\uDE3D\uDEA9]|\uD805[\uDCC6\uDDC1-\uDDD7\uDE41-\uDE43\uDF3C-\uDF3E]|\uD809[\uDC70-\uDC74]|\uD81A[\uDE6E\uDE6F\uDEF5\uDF37-\uDF3B\uDF44]|\uD82F\uDC9F|\uD836[\uDE87-\uDE8B]/;function C(t,e,i){return(e.f=e.inline=i)(t,e)}function S(t,e,i){return(e.f=e.block=i)(t,e)}function n(t){var e;return t.linkTitle=!1,t.linkHref=!1,t.linkText=!1,t.em=!1,t.strong=!1,t.strikethrough=!1,t.quote=0,t.indentedCode=!1,t.f==v&&(u?u:"xml"==(e=w.innerMode(g,t.htmlState)).mode.name&&null===e.state.tagStart&&!e.state.context&&e.state.tokenize.isInText)&&(t.f=T,t.block=a,t.htmlState=null),t.trailingSpace=0,t.trailingSpaceNewLine=!1,t.prevLine=t.thisLine,t.thisLine={stream:null},null}function a(t,e){var i=t.column()===e.indentation,n=!(n=e.prevLine.stream)||!/\S/.test(n.string),u=e.indentedCode,a=e.prevLine.hr,r=!1!==e.list,o=(e.listStack[e.listStack.length-1]||0)+3,l=(e.indentedCode=!1,e.indentation);if(null===e.indentationDiff&&(e.indentationDiff=e.indentation,r)){for(e.list=null;l<e.listStack[e.listStack.length-1];)e.listStack.pop(),e.listStack.length?e.indentation=e.listStack[e.listStack.length-1]:e.list=!1;!1!==e.list&&(e.indentationDiff=l-e.listStack[e.listStack.length-1])}var h,s=!(n||a||e.prevLine.header||r&&u||e.prevLine.fencedCodeEnd),a=(!1===e.list||a||n)&&e.indentation<=o&&t.match(f),g=null;return 4<=e.indentationDiff&&(u||e.prevLine.fencedCodeEnd||e.prevLine.header||n)?(t.skipToEnd(),e.indentedCode=!0,c.code):t.eatSpace()?null:i&&e.indentation<=o&&(g=t.match(D))&&g[1].length<=6?(e.quote=0,e.header=g[1].length,e.thisLine.header=!0,d.highlightFormatting&&(e.formatting="header"),e.f=e.inline,L(e)):e.indentation<=o&&t.eat(">")?(e.quote=i?1:e.quote+1,d.highlightFormatting&&(e.formatting="quote"),t.eatSpace(),L(e)):!a&&!e.setext&&i&&e.indentation<=o&&(g=t.match(k))?(u=g[1]?"ol":"ul",e.indentation=l+t.current().length,e.list=!0,e.quote=0,e.listStack.push(e.indentation),e.em=!1,e.strong=!1,e.code=!1,e.strikethrough=!1,d.taskLists&Line truncated
|
||||
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(a){"use strict";function e(e){for(var t;null!=(t=e.next());)if("`"==t&&!e.eat("`"))return"variable-2";return e.backUp(e.current().length-1),e.eatWhile(/\w/)?"variable-2":null}function t(e){return e.eat("@")&&(e.match("session."),e.match("local."),e.match("global.")),e.eat("'")?(e.match(/^.*'/),"variable-2"):e.eat('"')?(e.match(/^.*"/),"variable-2"):e.eat("`")?(e.match(/^.*`/),"variable-2"):e.match(/^[0-9a-zA-Z$\.\_]+/)?"variable-2":null}function r(e){return e.eat("N")?"atom":e.match(/^[a-zA-Z.#!?]/)?"variable-2":null}a.defineMode("sql",function(r,e){var i=e.client||{},n=e.atoms||{false:!0,true:!0,null:!0},s=e.builtin||y(v),o=e.keywords||y(f),l=e.operatorChars||/^[*+\-%<>!=&|~^\/]/,c=e.support||{},m=e.hooks||{},d=e.dateSQL||{date:!0,time:!0,timestamp:!0},u=!1!==e.backslashStringEscapes,p=e.brackets||/^[\{}\(\)\[\]]/,_=e.punctuation||/^[;.,:]/;function g(e,t){var r=e.next();if(m[r]){var a=m[r](e,t);if(!1!==a)return a}return c.hexNumber&&("0"==r&&e.match(/^[xX][0-9a-fA-F]+/)||("x"==r||"X"==r)&&e.match(/^'[0-9a-fA-F]*'/))||c.binaryNumber&&(("b"==r||"B"==r)&&e.match(/^'[01]*'/)||"0"==r&&e.match(/^b[01]+/))?"number":47<r.charCodeAt(0)&&r.charCodeAt(0)<58?(e.match(/^[0-9]*(\.[0-9]+)?([eE][-+]?[0-9]+)?/),c.decimallessFloat&&e.match(/^\.(?!\.)/),"number"):"?"==r&&(e.eatSpace()||e.eol()||e.eat(";"))?"variable-3":"'"==r||'"'==r&&c.doubleQuote?(t.tokenize=h(r),t.tokenize(e,t)):!(c.nCharCast&&("n"==r||"N"==r)||c.charsetCast&&"_"==r&&e.match(/[a-z][a-z0-9]*/i))||"'"!=e.peek()&&'"'!=e.peek()?c.escapeConstant&&("e"==r||"E"==r)&&("'"==e.peek()||'"'==e.peek()&&c.doubleQuote)?(t.tokenize=function(e,t){return(t.tokenize=h(e.next(),!0))(e,t)},"keyword"):c.commentSlashSlash&&"/"==r&&e.eat("/")||c.commentHash&&"#"==r||"-"==r&&e.eat("-")&&(!c.commentSpaceRequired||e.eat(" "))?(e.skipToEnd(),"comment"):"/"==r&&e.eat("*")?(t.tokenize=function a(i){return function(e,t){var r=e.match(/^.*?(\/\*|\*\/)/);return r?"/*"==r[1]?t.tokenize=a(i+1):t.tokenize=1<i?a(i-1):g:e.skipToEnd(),"comment"}}(1),t.tokenize(e,t)):"."==r?c.zerolessFloat&&e.match(/^(?:\d+(?:e[+-]?\d+)?)/i)?"number":e.match(/^\.+/)?null:e.match(/^[\w\d_$#]+/)?"variable-2":void 0:l.test(r)?(e.eatWhile(l),"operator"):p.test(r)?"bracket":_.test(r)?(e.eatWhile(_),"punctuation"):"{"==r&&(e.match(/^( )*(d|D|t|T|ts|TS)( )*'[^']*'( )*}/)||e.match(/^( )*(d|D|t|T|ts|TS)( )*"[^"]*"( )*}/))?"number":(e.eatWhile(/^[_\w\d]/),a=e.current().toLowerCase(),d.hasOwnProperty(a)&&(e.match(/^( )+'[^']*'/)||e.match(/^( )+"[^"]*"/))?"number":n.hasOwnProperty(a)?"atom":s.hasOwnProperty(a)?"type":o.hasOwnProperty(a)?"keyword":i.hasOwnProperty(a)?"builtin":null):"keyword"}function h(i,n){return function(e,t){for(var r,a=!1;null!=(r=e.next());){if(r==i&&!a){t.tokenize=g;break}a=(u||n)&&!a&&"\\"==r}return"string"}}function b(e,t,r){t.context={prev:t.context,indent:e.indentation(),col:e.column(),type:r}}return{startState:function(){return{tokenize:g,context:null}},token:function(e,t){if(e.sol()&&t.context&&null==t.context.align&&(t.context.align=!1),t.tokenize==g&&e.eatSpace())return null;var r=t.tokenize(e,t);if("comment"==r)return r;t.context&&null==t.context.align&&(t.context.align=!0);var a=e.current();return"("==a?b(e,t,")"):"["==a?b(e,t,"]"):t.context&&t.context.type==a&&((e=t).indent=e.context.indent,e.context=e.context.prev),r},indent:function(e,t){e=e.context;if(!e)return a.Pass;t=t.charAt(0)==e.type;return e.align?e.col+(t?0:1):e.indent+(t?0:r.indentUnit)},blockCommentStart:"/*",blockCommentEnd:"*/",lineComment:c.commentSlashSlash?"//":c.commentHash?"#":"--",closeBrackets:"()[]{}''\"\"``",config:e}});var f="alter and as asc between by count create delete desc distinct drop from group having in insert into is join like not on or order select set table union update values where limit ";function y(e){for(var t={},r=e.split(" "),a=0;a<r.length;++a)t[r[a]]=!0;return t}var v="bool boolean bit blob enum long longblob longtext medium mediumblob mediumint mediumtext time timestamp tinyblob tinyint tinytext text bigint int int1 int2 int3 int4 int8 integer float float4 float8 double char varbinary varchar varcharacter precision real date datetime year unsigned signed decimal numeric";a.defineMIME("text/x-sql",{name:"sql",keywords:y(f+"begin"),builtin:y(v),atoms:y("false true null unknown"),dateSQL:y("date time timestamp"),support:y("doubleQuote binaryNumber hexNumber")}),a.defineMIME("text/x-mssql",{name:"sql",client:y("$partition binary_checksum checksum connectionproperty context_info current_request_id error_line error_message error_number error_procedure error_severity error_state formatmessage get_filestream_transaction_context getansinull host_id host_name isnull isnumeric min_active_rowversion newid newsequentialid rowcount_big xact_state object_id"),keywords:y(f+"begin trigger proc view index for add constLine truncated
|
||||
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(e){"use strict";e.defineMode("properties",function(){return{token:function(e,i){var t=e.sol()||i.afterSection,n=e.eol();if(i.afterSection=!1,t&&(i.nextMultiline?(i.inMultiline=!0,i.nextMultiline=!1):i.position="def"),n&&!i.nextMultiline&&(i.inMultiline=!1,i.position="def"),t)for(;e.eatSpace(););n=e.next();return!t||"#"!==n&&"!"!==n&&";"!==n?t&&"["===n?(i.afterSection=!0,e.skipTo("]"),e.eat("]"),"header"):"="===n||":"===n?(i.position="quote",null):("\\"===n&&"quote"===i.position&&e.eol()&&(i.nextMultiline=!0),i.position):(i.position="comment",e.skipToEnd(),"comment")},startState:function(){return{position:"def",nextMultiline:!1,inMultiline:!1,afterSection:!1}}}}),e.defineMIME("text/x-properties","properties"),e.defineMIME("text/x-ini","properties")});;
|
||||
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(e){"use strict";e.defineMode("toml",function(){return{startState:function(){return{inString:!1,stringType:"",lhs:!0,inArray:0}},token:function(e,t){if(t.inString||'"'!=e.peek()&&"'"!=e.peek()||(t.stringType=e.peek(),e.next(),t.inString=!0),e.sol()&&0===t.inArray&&(t.lhs=!0),t.inString){for(;t.inString&&!e.eol();)e.peek()===t.stringType?(e.next(),t.inString=!1):"\\"===e.peek()?(e.next(),e.next()):e.match(/^.[^\\\"\']*/);return t.lhs?"property string":"string"}return t.inArray&&"]"===e.peek()?(e.next(),t.inArray--,"bracket"):t.lhs&&"["===e.peek()&&e.skipTo("]")?(e.next(),"]"===e.peek()&&e.next(),"atom"):"#"===e.peek()?(e.skipToEnd(),"comment"):e.eatSpace()?null:t.lhs&&e.eatWhile(function(e){return"="!=e&&" "!=e})?"property":t.lhs&&"="===e.peek()?(e.next(),t.lhs=!1,null):(t.lhs||!e.match(/^\d\d\d\d[\d\-\:\.T]*Z/))&&(t.lhs||!e.match("true")&&!e.match("false"))?t.lhs||"["!==e.peek()?!t.lhs&&e.match(/^\-?\d+(?:\.\d+)?/)?"number":(e.eatSpace()||e.next(),null):(t.inArray++,e.next(),"bracket"):"atom"}}}),e.defineMIME("text/x-toml","toml")});;
|
||||
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(e){"use strict";e.defineMode("nginx",function(e){function _(e){for(var _={},t=e.split(" "),i=0;i<t.length;++i)_[t[i]]=!0;return _}var s,i=_("break return rewrite set accept_mutex accept_mutex_delay access_log add_after_body add_before_body add_header addition_types aio alias allow ancient_browser ancient_browser_value auth_basic auth_basic_user_file auth_http auth_http_header auth_http_timeout autoindex autoindex_exact_size autoindex_localtime charset charset_types client_body_buffer_size client_body_in_file_only client_body_in_single_buffer client_body_temp_path client_body_timeout client_header_buffer_size client_header_timeout client_max_body_size connection_pool_size create_full_put_path daemon dav_access dav_methods debug_connection debug_points default_type degradation degrade deny devpoll_changes devpoll_events directio directio_alignment empty_gif env epoll_events error_log eventport_events expires fastcgi_bind fastcgi_buffer_size fastcgi_buffers fastcgi_busy_buffers_size fastcgi_cache fastcgi_cache_key fastcgi_cache_methods fastcgi_cache_min_uses fastcgi_cache_path fastcgi_cache_use_stale fastcgi_cache_valid fastcgi_catch_stderr fastcgi_connect_timeout fastcgi_hide_header fastcgi_ignore_client_abort fastcgi_ignore_headers fastcgi_index fastcgi_intercept_errors fastcgi_max_temp_file_size fastcgi_next_upstream fastcgi_param fastcgi_pass_header fastcgi_pass_request_body fastcgi_pass_request_headers fastcgi_read_timeout fastcgi_send_lowat fastcgi_send_timeout fastcgi_split_path_info fastcgi_store fastcgi_store_access fastcgi_temp_file_write_size fastcgi_temp_path fastcgi_upstream_fail_timeout fastcgi_upstream_max_fails flv geoip_city geoip_country google_perftools_profiles gzip gzip_buffers gzip_comp_level gzip_disable gzip_hash gzip_http_version gzip_min_length gzip_no_buffer gzip_proxied gzip_static gzip_types gzip_vary gzip_window if_modified_since ignore_invalid_headers image_filter image_filter_buffer image_filter_jpeg_quality image_filter_transparency imap_auth imap_capabilities imap_client_buffer index ip_hash keepalive_requests keepalive_timeout kqueue_changes kqueue_events large_client_header_buffers limit_conn limit_conn_log_level limit_rate limit_rate_after limit_req limit_req_log_level limit_req_zone limit_zone lingering_time lingering_timeout lock_file log_format log_not_found log_subrequest map_hash_bucket_size map_hash_max_size master_process memcached_bind memcached_buffer_size memcached_connect_timeout memcached_next_upstream memcached_read_timeout memcached_send_timeout memcached_upstream_fail_timeout memcached_upstream_max_fails merge_slashes min_delete_depth modern_browser modern_browser_value msie_padding msie_refresh multi_accept open_file_cache open_file_cache_errors open_file_cache_events open_file_cache_min_uses open_file_cache_valid open_log_file_cache output_buffers override_charset perl perl_modules perl_require perl_set pid pop3_auth pop3_capabilities port_in_redirect postpone_gzipping postpone_output protocol proxy proxy_bind proxy_buffer proxy_buffer_size proxy_buffering proxy_buffers proxy_busy_buffers_size proxy_cache proxy_cache_key proxy_cache_methods proxy_cache_min_uses proxy_cache_path proxy_cache_use_stale proxy_cache_valid proxy_connect_timeout proxy_headers_hash_bucket_size proxy_headers_hash_max_size proxy_hide_header proxy_ignore_client_abort proxy_ignore_headers proxy_intercept_errors proxy_max_temp_file_size proxy_method proxy_next_upstream proxy_pass_error_message proxy_pass_header proxy_pass_request_body proxy_pass_request_headers proxy_read_timeout proxy_redirect proxy_send_lowat proxy_send_timeout proxy_set_body proxy_set_header proxy_ssl_session_reuse proxy_store proxy_store_access proxy_temp_file_write_size proxy_temp_path proxy_timeout proxy_upstream_fail_timeout proxy_upstream_max_fails random_index read_ahead real_ip_header recursive_error_pages request_pool_size reset_timedout_connection resolver resolver_timeout rewrite_log rtsig_overflow_events rtsig_overflow_test rtsig_overflow_threshold rtsig_signo satisfy secure_link_secret send_lowat send_timeout sendfile sendfile_max_chunk server_name_in_redirect server_names_hash_bucket_size server_names_hash_max_size server_tokens set_real_ip_from smtp_auth smtp_capabilities smtp_client_buffer smtp_greeting_delay so_keepalive source_charset ssi ssi_ignore_recycled_buffers ssi_min_file_chunk ssi_silent_errors ssi_types ssi_value_length ssl ssl_certificate ssl_certificate_key ssl_ciphers ssl_client_certificate ssl_crl ssl_dhparam ssl_engine ssl_prefer_server_ciphers ssl_protocols ssl_session_cache ssl_session_timeout ssl_verify_client ssl_verify_depth starttls stub_status sub_filter sub_filter_once sub_filter_types tcp_nodelay tcp_nopush thread_stack_size timeout timer_resolution types_hash_bucket_size types_hash_maLine truncated
|
||||
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror"),require("../../addon/mode/simple")):"function"==typeof define&&define.amd?define(["../../lib/codemirror","../../addon/mode/simple"],e):e(CodeMirror)}(function(e){"use strict";var n="from",t=new RegExp("^(\\s*)\\b(from)\\b","i"),r=["run","cmd","entrypoint","shell"],o=new RegExp("^(\\s*)("+r.join("|")+")(\\s+\\[)","i"),l="expose",s=new RegExp("^(\\s*)("+l+")(\\s+)","i"),n="("+[n,l].concat(r).concat(["arg","from","maintainer","label","env","add","copy","volume","user","workdir","onbuild","stopsignal","healthcheck","shell"]).join("|")+")",l=new RegExp("^(\\s*)"+n+"(\\s*)(#.*)?$","i"),r=new RegExp("^(\\s*)"+n+"(\\s+)","i");e.defineSimpleMode("dockerfile",{start:[{regex:/^\s*#.*$/,sol:!0,token:"comment"},{regex:t,token:[null,"keyword"],sol:!0,next:"from"},{regex:l,token:[null,"keyword",null,"error"],sol:!0},{regex:o,token:[null,"keyword",null],sol:!0,next:"array"},{regex:s,token:[null,"keyword",null],sol:!0,next:"expose"},{regex:r,token:[null,"keyword",null],sol:!0,next:"arguments"},{regex:/./,token:null}],from:[{regex:/\s*$/,token:null,next:"start"},{regex:/(\s*)(#.*)$/,token:[null,"error"],next:"start"},{regex:/(\s*\S+\s+)(as)/i,token:[null,"keyword"],next:"start"},{token:null,next:"start"}],single:[{regex:/(?:[^\\']|\\.)/,token:"string"},{regex:/'/,token:"string",pop:!0}],double:[{regex:/(?:[^\\"]|\\.)/,token:"string"},{regex:/"/,token:"string",pop:!0}],array:[{regex:/\]/,token:null,next:"start"},{regex:/"(?:[^\\"]|\\.)*"?/,token:"string"}],expose:[{regex:/\d+$/,token:"number",next:"start"},{regex:/[^\d]+$/,token:null,next:"start"},{regex:/\d+/,token:"number"},{regex:/[^\d]+/,token:null},{token:null,next:"start"}],arguments:[{regex:/^\s*#.*$/,sol:!0,token:"comment"},{regex:/"(?:[^\\"]|\\.)*"?$/,token:"string",next:"start"},{regex:/"/,token:"string",push:"double"},{regex:/'(?:[^\\']|\\.)*'?$/,token:"string",next:"start"},{regex:/'/,token:"string",push:"single"},{regex:/[^#"']+[\\`]$/,token:null},{regex:/[^#"']+$/,token:null,next:"start"},{regex:/[^#"']+/,token:null},{token:null,next:"start"}],meta:{lineComment:"#"}}),e.defineMIME("text/x-dockerfile","dockerfile")});;
|
||||
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(e){"use strict";e.defineMode("lua",function(e,t){var n=e.indentUnit;function a(e){return new RegExp("^(?:"+e.join("|")+")$","i")}var r=a(t.specials||[]),o=a(["_G","_VERSION","assert","collectgarbage","dofile","error","getfenv","getmetatable","ipairs","load","loadfile","loadstring","module","next","pairs","pcall","print","rawequal","rawget","rawset","require","select","setfenv","setmetatable","tonumber","tostring","type","unpack","xpcall","coroutine.create","coroutine.resume","coroutine.running","coroutine.status","coroutine.wrap","coroutine.yield","debug.debug","debug.getfenv","debug.gethook","debug.getinfo","debug.getlocal","debug.getmetatable","debug.getregistry","debug.getupvalue","debug.setfenv","debug.sethook","debug.setlocal","debug.setmetatable","debug.setupvalue","debug.traceback","close","flush","lines","read","seek","setvbuf","write","io.close","io.flush","io.input","io.lines","io.open","io.output","io.popen","io.read","io.stderr","io.stdin","io.stdout","io.tmpfile","io.type","io.write","math.abs","math.acos","math.asin","math.atan","math.atan2","math.ceil","math.cos","math.cosh","math.deg","math.exp","math.floor","math.fmod","math.frexp","math.huge","math.ldexp","math.log","math.log10","math.max","math.min","math.modf","math.pi","math.pow","math.rad","math.random","math.randomseed","math.sin","math.sinh","math.sqrt","math.tan","math.tanh","os.clock","os.date","os.difftime","os.execute","os.exit","os.getenv","os.remove","os.rename","os.setlocale","os.time","os.tmpname","package.cpath","package.loaded","package.loaders","package.loadlib","package.path","package.preload","package.seeall","string.byte","string.char","string.dump","string.find","string.format","string.gmatch","string.gsub","string.len","string.lower","string.match","string.rep","string.reverse","string.sub","string.upper","table.concat","table.insert","table.maxn","table.remove","table.sort"]),i=a(["and","break","elseif","false","nil","not","or","return","true","function","end","if","then","else","do","while","repeat","until","for","in","local"]),l=a(["function","if","repeat","do","\\(","{"]),s=a(["end","until","\\)","}"]),u=new RegExp("^(?:"+["end","until","\\)","}","else","elseif"].join("|")+")","i");function c(e){for(var t=0;e.eat("=");)++t;return e.eat("["),t}function m(e,t){var r,n=e.next();return"-"==n&&e.eat("-")?e.eat("[")&&e.eat("[")?(t.cur=d(c(e),"comment"))(e,t):(e.skipToEnd(),"comment"):'"'==n||"'"==n?(t.cur=(r=n,function(e,t){for(var n,a=!1;null!=(n=e.next())&&(n!=r||a);)a=!a&&"\\"==n;return a||(t.cur=m),"string"}))(e,t):"["==n&&/[\[=]/.test(e.peek())?(t.cur=d(c(e),"string"))(e,t):/\d/.test(n)?(e.eatWhile(/[\w.%]/),"number"):/[\w_]/.test(n)?(e.eatWhile(/[\w\\\-_.]/),"variable"):null}function d(r,o){return function(e,t){for(var n,a=null;null!=(n=e.next());)if(null==a)"]"==n&&(a=0);else if("="==n)++a;else{if("]"==n&&a==r){t.cur=m;break}a=null}return o}}return{startState:function(e){return{basecol:e||0,indentDepth:0,cur:m}},token:function(e,t){if(e.eatSpace())return null;var n=t.cur(e,t),e=e.current();return"variable"==n&&(i.test(e)?n="keyword":o.test(e)?n="builtin":r.test(e)&&(n="variable-2")),"comment"!=n&&"string"!=n&&(l.test(e)?++t.indentDepth:s.test(e)&&--t.indentDepth),n},indent:function(e,t){t=u.test(t);return e.basecol+n*(e.indentDepth-(t?1:0))},electricInput:/^\s*(?:end|until|else|\)|\})$/,lineComment:"--",blockCommentStart:"--[[",blockCommentEnd:"]]"}}),e.defineMIME("text/x-lua","lua")});;
|
||||
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror"),require("../../addon/mode/simple")):"function"==typeof define&&define.amd?define(["../../lib/codemirror","../../addon/mode/simple"],e):e(CodeMirror)}(function(e){"use strict";e.defineSimpleMode("rust",{start:[{regex:/b?"/,token:"string",next:"string"},{regex:/b?r"/,token:"string",next:"string_raw"},{regex:/b?r#+"/,token:"string",next:"string_raw_hash"},{regex:/'(?:[^'\\]|\\(?:[nrt0'"]|x[\da-fA-F]{2}|u\{[\da-fA-F]{6}\}))'/,token:"string-2"},{regex:/b'(?:[^']|\\(?:['\\nrt0]|x[\da-fA-F]{2}))'/,token:"string-2"},{regex:/(?:(?:[0-9][0-9_]*)(?:(?:[Ee][+-]?[0-9_]+)|\.[0-9_]+(?:[Ee][+-]?[0-9_]+)?)(?:f32|f64)?)|(?:0(?:b[01_]+|(?:o[0-7_]+)|(?:x[0-9a-fA-F_]+))|(?:[0-9][0-9_]*))(?:u8|u16|u32|u64|i8|i16|i32|i64|isize|usize)?/,token:"number"},{regex:/(let(?:\s+mut)?|fn|enum|mod|struct|type|union)(\s+)([a-zA-Z_][a-zA-Z0-9_]*)/,token:["keyword",null,"def"]},{regex:/(?:abstract|alignof|as|async|await|box|break|continue|const|crate|do|dyn|else|enum|extern|fn|for|final|if|impl|in|loop|macro|match|mod|move|offsetof|override|priv|proc|pub|pure|ref|return|self|sizeof|static|struct|super|trait|type|typeof|union|unsafe|unsized|use|virtual|where|while|yield)\b/,token:"keyword"},{regex:/\b(?:Self|isize|usize|char|bool|u8|u16|u32|u64|f16|f32|f64|i8|i16|i32|i64|str|Option)\b/,token:"atom"},{regex:/\b(?:true|false|Some|None|Ok|Err)\b/,token:"builtin"},{regex:/\b(fn)(\s+)([a-zA-Z_][a-zA-Z0-9_]*)/,token:["keyword",null,"def"]},{regex:/#!?\[.*\]/,token:"meta"},{regex:/\/\/.*/,token:"comment"},{regex:/\/\*/,token:"comment",next:"comment"},{regex:/[-+\/*=<>!]+/,token:"operator"},{regex:/[a-zA-Z_]\w*!/,token:"variable-3"},{regex:/[a-zA-Z_]\w*/,token:"variable"},{regex:/[\{\[\(]/,indent:!0},{regex:/[\}\]\)]/,dedent:!0}],string:[{regex:/"/,token:"string",next:"start"},{regex:/(?:[^\\"]|\\(?:.|$))*/,token:"string"}],string_raw:[{regex:/"/,token:"string",next:"start"},{regex:/[^"]*/,token:"string"}],string_raw_hash:[{regex:/"#+/,token:"string",next:"start"},{regex:/(?:[^"]|"(?!#))*/,token:"string"}],comment:[{regex:/.*?\*\//,token:"comment",next:"start"},{regex:/.*/,token:"comment"}],meta:{dontIndentStates:["comment"],electricInput:/^\s*\}$/,blockCommentStart:"/*",blockCommentEnd:"*/",lineComment:"//",fold:"brace"}}),e.defineMIME("text/x-rustsrc","rust"),e.defineMIME("text/rust","rust")});;
|
||||
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(e){"use strict";e.defineMode("diff",function(){var o={"+":"positive","-":"negative","@":"meta"};return{token:function(e){var r=e.string.search(/[\t ]+?$/);if(!e.sol()||0===r)return e.skipToEnd(),("error "+(o[e.string.charAt(0)]||"")).replace(/ $/,"");var i=o[e.peek()]||e.skipToEnd();return-1===r?e.skipToEnd():e.pos=r,i}}}),e.defineMIME("text/x-diff","diff")});;
|
||||
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(o){"use strict";function e(e){for(var t={},n=0,r=e.length;n<r;++n)t[e[n]]=!0;return t}var t=["alias","and","BEGIN","begin","break","case","class","def","defined?","do","else","elsif","END","end","ensure","false","for","if","in","module","next","not","or","redo","rescue","retry","return","self","super","then","true","undef","unless","until","when","while","yield","nil","raise","throw","catch","fail","loop","callcc","caller","lambda","proc","public","protected","private","require","load","require_relative","extend","autoload","__END__","__FILE__","__LINE__","__dir__"],a=e(t),u=e(["def","class","case","for","while","until","module","catch","loop","proc","begin"]),p=e(["end","until"]),k={"[":"]","{":"}","(":")"},m={"]":"[","}":"{",")":"("};o.defineMode("ruby",function(i){var l;function f(e,t,n){return n.tokenize.push(e),e(t,n)}function d(e,t){if(e.sol()&&e.match("=begin")&&e.eol())return t.tokenize.push(c),"comment";if(e.eatSpace())return null;var n,r,i,o,a,u=e.next();if("`"==u||"'"==u||'"'==u)return f(s(u,"string",'"'==u||"`"==u),e,t);if("/"==u)return function(e){var t,n=e.pos,r=0,i=!1,o=!1;for(;null!=(t=e.next());)if(o)o=!1;else{if(-1<"[{(".indexOf(t))r++;else if(-1<"]})".indexOf(t)){if(--r<0)break}else if("/"==t&&0==r){i=!0;break}o="\\"==t}return e.backUp(e.pos-n),i}(e)?f(s(u,"string-2",!0),e,t):"operator";if("%"==u)return i="string",n=!0,e.eat("s")?i="atom":e.eat(/[WQ]/)?i="string":e.eat(/[r]/)?i="string-2":e.eat(/[wxq]/)&&(n=!(i="string")),(r=e.eat(/[^\w\s=]/))?f(s(r=k.propertyIsEnumerable(r)?k[r]:r,i,n,!0),e,t):"operator";if("#"==u)return e.skipToEnd(),"comment";if("<"==u&&(r=e.match(/^<([-~])[\`\"\']?([a-zA-Z_?]\w*)[\`\"\']?(?:;|$)/)))return f((o=r[2],a=r[1],function(e,t){return a&&e.eatSpace(),e.match(o)?t.tokenize.pop():e.skipToEnd(),"string"}),e,t);if("0"==u)return e.eat("x")?e.eatWhile(/[\da-fA-F]/):e.eat("b")?e.eatWhile(/[01]/):e.eatWhile(/[0-7]/),"number";if(/\d/.test(u))return e.match(/^[\d_]*(?:\.[\d_]+)?(?:[eE][+\-]?[\d_]+)?/),"number";if("?"!=u)return":"==u?e.eat("'")?f(s("'","atom",!1),e,t):e.eat('"')?f(s('"',"atom",!0),e,t):e.eat(/[\<\>]/)?(e.eat(/[\<\>]/),"atom"):e.eat(/[\+\-\*\/\&\|\:\!]/)?"atom":e.eat(/[a-zA-Z$@_\xa1-\uffff]/)?(e.eatWhile(/[\w$\xa1-\uffff]/),e.eat(/[\?\!\=]/),"atom"):"operator":"@"==u&&e.match(/^@?[a-zA-Z_\xa1-\uffff]/)?(e.eat("@"),e.eatWhile(/[\w\xa1-\uffff]/),"variable-2"):"$"==u?(e.eat(/[a-zA-Z_]/)?e.eatWhile(/[\w]/):e.eat(/\d/)?e.eat(/\d/):e.next(),"variable-3"):/[a-zA-Z_\xa1-\uffff]/.test(u)?(e.eatWhile(/[\w\xa1-\uffff]/),e.eat(/[\?\!]/),e.eat(":")?"atom":"ident"):"|"!=u||!t.varList&&"{"!=t.lastTok&&"do"!=t.lastTok?/[\(\)\[\]{}\\;]/.test(u)?(l=u,null):"-"==u&&e.eat(">")?"arrow":/[=+\-\/*:\.^%<>~|]/.test(u)?(i=e.eatWhile(/[=+\-\/*:\.^%<>~|]/),"."!=u||i||(l="."),"operator"):null:(l="|",null);for(;e.match(/^\\[CM]-/););return e.eat("\\")?e.eatWhile(/\w/):e.next(),"string"}function s(i,o,a,u){return function(e,t){var n,r=!1;for("read-quoted-paused"===t.context.type&&(t.context=t.context.prev,e.eat("}"));null!=(n=e.next());){if(n==i&&(u||!r)){t.tokenize.pop();break}if(a&&"#"==n&&!r){if(e.eat("{")){"}"==i&&(t.context={prev:t.context,type:"read-quoted-paused"}),t.tokenize.push(function n(r){return r=r||1,function(e,t){if("}"==e.peek()){if(1==r)return t.tokenize.pop(),t.tokenize[t.tokenize.length-1](e,t);t.tokenize[t.tokenize.length-1]=n(r-1)}else"{"==e.peek()&&(t.tokenize[t.tokenize.length-1]=n(r+1));return d(e,t)}}());break}if(/[@\$]/.test(e.peek())){t.tokenize.push(function(){var n=!1;return function(e,t){return n?(t.tokenize.pop(),t.tokenize[t.tokenize.length-1](e,t)):(n=!0,d(e,t))}}());break}}r=!r&&"\\"==n}return o}}function c(e,t){return e.sol()&&e.match("=end")&&e.eol()&&t.tokenize.pop(),e.skipToEnd(),"comment"}return{startState:function(){return{tokenize:[d],indented:0,context:{type:"top",indented:-i.indentUnit},continuedLine:!1,lastTok:null,varList:!1}},token:function(e,t){l=null,e.sol()&&(t.indented=e.indentation());var n,r,i=t.tokenize[t.tokenize.length-1](e,t),o=l;return"ident"==i&&(r=e.current(),"keyword"==(i="."==t.lastTok?"property":a.propertyIsEnumerable(e.current())?"keyword":/^[A-Z]/.test(r)?"tag":"def"==t.lastTok||"class"==t.lastTok||t.varList?"def":"variable")&&(u.propertyIsEnumerable(o=r)?n="indent":p.propertyIsEnumerable(r)?n="dedent":(("if"==r||"unless"==r)&&e.column()==e.indentation()||"do"==r&&t.context.indented<t.indented)&&(n="indent"))),(l||i&&"comment"!=i)&&(t.lastTok=o),"|"==l&&(t.varList=!t.varList),"indent"==n||/[\(\[\{]/.test(l)?t.context={prev:t.context,type:l||i,indented:t.indented}:("dedent"==n||/[\)\]\}]/.test(l))&&t.context.prev&&(t.context=t.context.prev),e.eol()&&(t.continuedLine="\\"==l||"operator"==i),i},indent:function(e,t){if(e.tokenize[e.tokenize.length-1]!=d)return o.Pass;var n=t&&t.charAt(0),r=e.context,n=r.type==m[n]||"keyword"==r.type&Line truncated
|
||||
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(e){"use strict";function R(e,t){return e.string.charAt(e.pos+(t||0))}function c(e,t){var r;return t?(r=e.pos-t,e.string.substr(0<=r?r:0,t)):e.string.substr(0,e.pos-1)}function p(e,t){var r=e.string.length,n=r-e.pos+1;return e.string.substr(e.pos,t&&t<r?t:n)}function _(e,t){var r,t=e.pos+t;t<=0?e.pos=0:t>=(r=e.string.length-1)?e.pos=r:e.pos=t}e.defineMode("perl",function(){var a={"->":4,"++":4,"--":4,"**":4,"=~":4,"!~":4,"*":4,"/":4,"%":4,x:4,"+":4,"-":4,".":4,"<<":4,">>":4,"<":4,">":4,"<=":4,">=":4,lt:4,gt:4,le:4,ge:4,"==":4,"!=":4,"<=>":4,eq:4,ne:4,cmp:4,"~~":4,"&":4,"|":4,"^":4,"&&":4,"||":4,"//":4,"..":4,"...":4,"?":4,":":4,"=":4,"+=":4,"-=":4,"*=":4,",":4,"=>":4,"::":4,not:4,and:4,or:4,xor:4,BEGIN:[5,1],END:[5,1],PRINT:[5,1],PRINTF:[5,1],GETC:[5,1],READ:[5,1],READLINE:[5,1],DESTROY:[5,1],TIE:[5,1],TIEHANDLE:[5,1],UNTIE:[5,1],STDIN:5,STDIN_TOP:5,STDOUT:5,STDOUT_TOP:5,STDERR:5,STDERR_TOP:5,$ARG:5,$_:5,"@ARG":5,"@_":5,$LIST_SEPARATOR:5,'$"':5,$PROCESS_ID:5,$PID:5,$$:5,$REAL_GROUP_ID:5,$GID:5,"$(":5,$EFFECTIVE_GROUP_ID:5,$EGID:5,"$)":5,$PROGRAM_NAME:5,$0:5,$SUBSCRIPT_SEPARATOR:5,$SUBSEP:5,"$;":5,$REAL_USER_ID:5,$UID:5,"$<":5,$EFFECTIVE_USER_ID:5,$EUID:5,"$>":5,$a:5,$b:5,$COMPILING:5,"$^C":5,$DEBUGGING:5,"$^D":5,"${^ENCODING}":5,$ENV:5,"%ENV":5,$SYSTEM_FD_MAX:5,"$^F":5,"@F":5,"${^GLOBAL_PHASE}":5,"$^H":5,"%^H":5,"@INC":5,"%INC":5,$INPLACE_EDIT:5,"$^I":5,"$^M":5,$OSNAME:5,"$^O":5,"${^OPEN}":5,$PERLDB:5,"$^P":5,$SIG:5,"%SIG":5,$BASETIME:5,"$^T":5,"${^TAINT}":5,"${^UNICODE}":5,"${^UTF8CACHE}":5,"${^UTF8LOCALE}":5,$PERL_VERSION:5,"$^V":5,"${^WIN32_SLOPPY_STAT}":5,$EXECUTABLE_NAME:5,"$^X":5,$1:5,$MATCH:5,"$&":5,"${^MATCH}":5,$PREMATCH:5,"$`":5,"${^PREMATCH}":5,$POSTMATCH:5,"$'":5,"${^POSTMATCH}":5,$LAST_PAREN_MATCH:5,"$+":5,$LAST_SUBMATCH_RESULT:5,"$^N":5,"@LAST_MATCH_END":5,"@+":5,"%LAST_PAREN_MATCH":5,"%+":5,"@LAST_MATCH_START":5,"@-":5,"%LAST_MATCH_START":5,"%-":5,$LAST_REGEXP_CODE_RESULT:5,"$^R":5,"${^RE_DEBUG_FLAGS}":5,"${^RE_TRIE_MAXBUF}":5,$ARGV:5,"@ARGV":5,ARGV:5,ARGVOUT:5,$OUTPUT_FIELD_SEPARATOR:5,$OFS:5,"$,":5,$INPUT_LINE_NUMBER:5,$NR:5,"$.":5,$INPUT_RECORD_SEPARATOR:5,$RS:5,"$/":5,$OUTPUT_RECORD_SEPARATOR:5,$ORS:5,"$\\":5,$OUTPUT_AUTOFLUSH:5,"$|":5,$ACCUMULATOR:5,"$^A":5,$FORMAT_FORMFEED:5,"$^L":5,$FORMAT_PAGE_NUMBER:5,"$%":5,$FORMAT_LINES_LEFT:5,"$-":5,$FORMAT_LINE_BREAK_CHARACTERS:5,"$:":5,$FORMAT_LINES_PER_PAGE:5,"$=":5,$FORMAT_TOP_NAME:5,"$^":5,$FORMAT_NAME:5,"$~":5,"${^CHILD_ERROR_NATIVE}":5,$EXTENDED_OS_ERROR:5,"$^E":5,$EXCEPTIONS_BEING_CAUGHT:5,"$^S":5,$WARNING:5,"$^W":5,"${^WARNING_BITS}":5,$OS_ERROR:5,$ERRNO:5,"$!":5,"%OS_ERROR":5,"%ERRNO":5,"%!":5,$CHILD_ERROR:5,"$?":5,$EVAL_ERROR:5,"$@":5,$OFMT:5,"$#":5,"$*":5,$ARRAY_BASE:5,"$[":5,$OLD_PERL_VERSION:5,"$]":5,if:[1,1],elsif:[1,1],else:[1,1],while:[1,1],unless:[1,1],for:[1,1],foreach:[1,1],abs:1,accept:1,alarm:1,atan2:1,bind:1,binmode:1,bless:1,bootstrap:1,break:1,caller:1,chdir:1,chmod:1,chomp:1,chop:1,chown:1,chr:1,chroot:1,close:1,closedir:1,connect:1,continue:[1,1],cos:1,crypt:1,dbmclose:1,dbmopen:1,default:1,defined:1,delete:1,die:1,do:1,dump:1,each:1,endgrent:1,endhostent:1,endnetent:1,endprotoent:1,endpwent:1,endservent:1,eof:1,eval:1,exec:1,exists:1,exit:1,exp:1,fcntl:1,fileno:1,flock:1,fork:1,format:1,formline:1,getc:1,getgrent:1,getgrgid:1,getgrnam:1,gethostbyaddr:1,gethostbyname:1,gethostent:1,getlogin:1,getnetbyaddr:1,getnetbyname:1,getnetent:1,getpeername:1,getpgrp:1,getppid:1,getpriority:1,getprotobyname:1,getprotobynumber:1,getprotoent:1,getpwent:1,getpwnam:1,getpwuid:1,getservbyname:1,getservbyport:1,getservent:1,getsockname:1,getsockopt:1,given:1,glob:1,gmtime:1,goto:1,grep:1,hex:1,import:1,index:1,int:1,ioctl:1,join:1,keys:1,kill:1,last:1,lc:1,lcfirst:1,length:1,link:1,listen:1,local:2,localtime:1,lock:1,log:1,lstat:1,m:null,map:1,mkdir:1,msgctl:1,msgget:1,msgrcv:1,msgsnd:1,my:2,new:1,next:1,no:1,oct:1,open:1,opendir:1,ord:1,our:2,pack:1,package:1,pipe:1,pop:1,pos:1,print:1,printf:1,prototype:1,push:1,q:null,qq:null,qr:null,quotemeta:null,qw:null,qx:null,rand:1,read:1,readdir:1,readline:1,readlink:1,readpipe:1,recv:1,redo:1,ref:1,rename:1,require:1,reset:1,return:1,reverse:1,rewinddir:1,rindex:1,rmdir:1,s:null,say:1,scalar:1,seek:1,seekdir:1,select:1,semctl:1,semget:1,semop:1,send:1,setgrent:1,sethostent:1,setnetent:1,setpgrp:1,setpriority:1,setprotoent:1,setpwent:1,setservent:1,setsockopt:1,shift:1,shmctl:1,shmget:1,shmread:1,shmwrite:1,shutdown:1,sin:1,sleep:1,socket:1,socketpair:1,sort:1,splice:1,split:1,sprintf:1,sqrt:1,srand:1,stat:1,state:1,study:1,sub:1,substr:1,symlink:1,syscall:1,sysopen:1,sysread:1,sysseek:1,system:1,syswrite:1,tell:1,telldir:1,tie:1,tied:1,time:1,times:1,tr:null,truncate:1,uc:1,ucfirst:1,umask:1,undef:1,unlink:1,unpack:1,unshift:1,untie:1,use:1,utime:1,values:1,vec:1,wait:1,waitpid:1,wantarray:1,warn:1,whenLine truncated
|
||||
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(e){"use strict";e.defineMode("cmake",function(){var i=/({)?[a-zA-Z0-9_]+(})?/;function r(e,n){for(var t,i,r=!1;!e.eol()&&(t=e.next())!=n.pending;){if("$"===t&&"\\"!=i&&'"'==n.pending){r=!0;break}i=t}return r&&e.backUp(1),t==n.pending?n.continueString=!1:n.continueString=!0,"string"}return{startState:function(){var e={inDefinition:!1,inInclude:!1,continueString:!1,pending:!1};return e},token:function(e,n){return e.eatSpace()?null:(n=n,"$"===(t=(e=e).next())?e.match(i)?"variable-2":"variable":n.continueString?(e.backUp(1),r(e,n)):e.match(/(\s+)?\w+\(/)||e.match(/(\s+)?\w+\ \(/)?(e.backUp(1),"def"):"#"==t?(e.skipToEnd(),"comment"):"'"==t||'"'==t?(n.pending=t,r(e,n)):"("==t||")"==t?"bracket":t.match(/[0-9]/)?"number":(e.eatWhile(/[\w-]/),null));var t}}}),e.defineMIME("text/x-cmake","cmake")});;
|
||||
@@ -0,0 +1,326 @@
|
||||
// SQLite editor of the file manager: the agent opens the database and runs
|
||||
// the queries, so a database in use keeps its locks. Tables and views in
|
||||
// tabs, rows a page at a time; a click on a value edits it (Enter saves,
|
||||
// Esc cancels, Shift+Enter a new line, the ∅ button makes it NULL). Rows are
|
||||
// added and deleted, and a tab runs any SQL.
|
||||
(function () {
|
||||
'use strict';
|
||||
var PAGE = 200;
|
||||
var esc = function (s) { return String(s).replace(/[&<>"]/g, function (c) { return {'&': '&', '<': '<', '>': '>', '"': '"'}[c]; }); };
|
||||
|
||||
function cellHTML(c) {
|
||||
if (c.t === 'n') return '<i class="dbx-null">NULL</i>';
|
||||
if (c.t === 'b') return '<i class="dbx-null">BLOB ' + esc(c.v) + ' Б</i>';
|
||||
var v = c.v || '';
|
||||
return esc(v.length > 300 ? v.slice(0, 300) + '…' : v);
|
||||
}
|
||||
// The type a typed value goes as: what the value was, or what the column
|
||||
// is declared as, when the text fits it.
|
||||
function typeFor(text, was, decl) {
|
||||
var d = (decl || '').toUpperCase();
|
||||
var int = /^\s*-?\d+\s*$/.test(text), num = text.trim() !== '' && isFinite(Number(text));
|
||||
if (was === 'i' || (was !== 's' && /INT/.test(d))) return int ? 'i' : num ? 'r' : 's';
|
||||
if (was === 'r' || (was !== 's' && /REAL|FLOA|DOUB|NUM|DEC/.test(d))) return num ? 'r' : 's';
|
||||
return 's';
|
||||
}
|
||||
|
||||
function open(box, path, call, toast) {
|
||||
var S = {tables: [], cur: null, offset: 0, data: null, sel: new Set()};
|
||||
box.innerHTML =
|
||||
'<div class="dbx">' +
|
||||
'<div class="dbx-tabs" role="tablist"></div>' +
|
||||
'<div class="dbx-tools">' +
|
||||
' <span class="muted dbx-info"></span>' +
|
||||
' <span class="dbx-pager"><button type="button" class="btn ghost small icon dbx-prev" title="Назад" aria-label="Назад">‹</button>' +
|
||||
' <span class="muted dbx-range"></span><button type="button" class="btn ghost small icon dbx-next" title="Дальше" aria-label="Дальше">›</button></span>' +
|
||||
' <span class="dbx-sp"></span>' +
|
||||
' <button type="button" class="btn ghost small dbx-add">+ Строка</button>' +
|
||||
' <button type="button" class="btn danger small dbx-del" disabled>Удалить</button>' +
|
||||
'</div>' +
|
||||
'<div class="dbx-grid"></div>' +
|
||||
'<div class="dbx-sql" hidden>' +
|
||||
' <textarea class="dbx-q" spellcheck="false" placeholder="SELECT * FROM … - Ctrl+Enter выполнить"></textarea>' +
|
||||
' <div class="dbx-sqlbar"><button type="button" class="dbx-run">Выполнить</button><span class="muted dbx-sqlmsg"></span></div>' +
|
||||
' <div class="dbx-sqlres"></div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
var $ = function (s) { return box.querySelector(s); };
|
||||
|
||||
function busy(on) { box.querySelector('.dbx').classList.toggle('busy', on); }
|
||||
function req(op, args) {
|
||||
args.path = path;
|
||||
busy(true);
|
||||
return call(op, args).then(function (r) { busy(false); return r; }, function (e) { busy(false); throw e; });
|
||||
}
|
||||
|
||||
function tabs() {
|
||||
var t = $('.dbx-tabs');
|
||||
t.innerHTML = '';
|
||||
S.tables.forEach(function (x) {
|
||||
var b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.textContent = x.name;
|
||||
if (x.type === 'view') b.title = 'представление - только чтение';
|
||||
b.className = x.name === S.cur ? 'on' : '';
|
||||
b.onclick = function () { show(x.name, 0); };
|
||||
t.appendChild(b);
|
||||
});
|
||||
var q = document.createElement('button');
|
||||
q.type = 'button';
|
||||
q.textContent = 'SQL';
|
||||
q.className = 'dbx-sqltab' + (S.cur === null ? ' on' : '');
|
||||
q.onclick = sqlTab;
|
||||
t.appendChild(q);
|
||||
}
|
||||
|
||||
function show(name, offset) {
|
||||
S.cur = name; S.offset = offset; S.sel.clear();
|
||||
tabs();
|
||||
$('.dbx-sql').hidden = true;
|
||||
$('.dbx-grid').hidden = false;
|
||||
$('.dbx-tools').hidden = false;
|
||||
return req('db.rows', {table: name, offset: offset, limit: PAGE}).then(function (r) {
|
||||
if (!r.ok) { $('.dbx-grid').innerHTML = '<p class="dv-note">' + esc(r.error || 'ошибка') + '</p>'; return; }
|
||||
S.data = r.data;
|
||||
draw();
|
||||
});
|
||||
}
|
||||
|
||||
function draw() {
|
||||
var d = S.data, t = d.table, editable = !!t.key;
|
||||
// An INTEGER PRIMARY KEY column already shows the rowid.
|
||||
var showRowid = t.key === 'rowid' && !t.alias;
|
||||
$('.dbx-info').textContent = d.total + ' ' + plural(d.total) + (t.type === 'view' ? ' · представление' : !editable ? ' · только чтение' : '');
|
||||
$('.dbx-range').textContent = d.rows.length ? (d.offset + 1) + '–' + (d.offset + d.rows.length) : '';
|
||||
$('.dbx-prev').disabled = d.offset <= 0;
|
||||
$('.dbx-next').disabled = d.offset + d.rows.length >= d.total;
|
||||
$('.dbx-pager').hidden = d.total <= PAGE;
|
||||
$('.dbx-add').hidden = t.type !== 'table';
|
||||
$('.dbx-del').hidden = !editable;
|
||||
selInfo();
|
||||
var h = '<table class="dbx-t"><thead><tr>' + (editable ? '<th class="dbx-ck"><input type="checkbox" class="dbx-all" aria-label="Выбрать все"></th>' : '') +
|
||||
(showRowid ? '<th class="dbx-k">rowid</th>' : '');
|
||||
t.columns.forEach(function (c) {
|
||||
h += '<th title="' + esc(c.type + (c.pk ? ' · ключ' : '') + (c.notnull ? ' · NOT NULL' : '')) + '">' + esc(c.name) +
|
||||
(c.type ? '<small>' + esc(c.type.toLowerCase()) + '</small>' : '') + '</th>';
|
||||
});
|
||||
h += '</tr></thead><tbody>';
|
||||
d.rows.forEach(function (row, i) {
|
||||
h += '<tr data-i="' + i + '">' + (editable ? '<td class="dbx-ck"><input type="checkbox" aria-label="Выбрать строку"></td>' : '') +
|
||||
(showRowid ? '<td class="dbx-k">' + esc(row.k) + '</td>' : '');
|
||||
row.v.forEach(function (c, j) {
|
||||
h += '<td data-j="' + j + '" class="' + (c.t === 'i' || c.t === 'r' ? 'num' : '') + (editable && c.t !== 'b' ? ' ed' : '') + '">' + cellHTML(c) + '</td>';
|
||||
});
|
||||
h += '</tr>';
|
||||
});
|
||||
h += '</tbody></table>';
|
||||
if (!d.rows.length) h += '<p class="dv-note">строк нет</p>';
|
||||
$('.dbx-grid').innerHTML = h;
|
||||
$('.dbx-grid').scrollTop = 0;
|
||||
fixWidths($('.dbx-grid table'));
|
||||
}
|
||||
// The columns keep the widths they first came out at, so a value being
|
||||
// edited does not move the table about.
|
||||
function fixWidths(table) {
|
||||
if (!table) return;
|
||||
var ths = table.querySelectorAll('thead th'), ws = [], sum = 0;
|
||||
ths.forEach(function (th) { var w = Math.ceil(th.getBoundingClientRect().width); ws.push(w); sum += w; });
|
||||
ths.forEach(function (th, i) { th.style.width = ws[i] + 'px'; });
|
||||
table.style.width = sum + 'px';
|
||||
table.style.tableLayout = 'fixed';
|
||||
}
|
||||
function plural(n) {
|
||||
var m10 = n % 10, m100 = n % 100;
|
||||
return m10 === 1 && m100 !== 11 ? 'строка' : m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14) ? 'строки' : 'строк';
|
||||
}
|
||||
function selInfo() {
|
||||
var b = $('.dbx-del');
|
||||
b.disabled = !S.sel.size;
|
||||
b.textContent = S.sel.size ? 'Удалить (' + S.sel.size + ')' : 'Удалить';
|
||||
}
|
||||
|
||||
$('.dbx-prev').onclick = function () { show(S.cur, Math.max(0, S.offset - PAGE)); };
|
||||
$('.dbx-next').onclick = function () { show(S.cur, S.offset + PAGE); };
|
||||
|
||||
// ---- selecting rows ----
|
||||
$('.dbx-grid').addEventListener('change', function (ev) {
|
||||
var el = ev.target;
|
||||
if (el.classList.contains('dbx-all')) {
|
||||
box.querySelectorAll('tbody .dbx-ck input').forEach(function (c, i) { c.checked = el.checked; el.checked ? S.sel.add(i) : S.sel.delete(i); });
|
||||
} else if (el.closest('tbody .dbx-ck')) {
|
||||
var i = +el.closest('tr').dataset.i;
|
||||
el.checked ? S.sel.add(i) : S.sel.delete(i);
|
||||
}
|
||||
selInfo();
|
||||
});
|
||||
|
||||
// ---- editing a value ----
|
||||
var editing = null;
|
||||
$('.dbx-grid').addEventListener('click', function (ev) {
|
||||
var td = ev.target.closest('td.ed');
|
||||
if (!td || td.querySelector('textarea') || ev.target.closest('.dbx-edit')) return;
|
||||
edit(td);
|
||||
});
|
||||
function edit(td) {
|
||||
if (editing) finish(editing, true);
|
||||
var i = +td.parentNode.dataset.i, j = +td.dataset.j;
|
||||
var row = S.data.rows[i], cell = row.v[j], col = S.data.table.columns[j];
|
||||
var hgt = td.offsetHeight;
|
||||
td.classList.add('editing');
|
||||
td.innerHTML = '<div class="dbx-edit"><textarea spellcheck="false"></textarea><button type="button" class="dbx-nullb" title="Сделать NULL" aria-label="Сделать NULL">∅</button></div>';
|
||||
var ta = td.querySelector('textarea');
|
||||
ta.value = cell.t === 'n' ? '' : cell.v;
|
||||
ta.placeholder = cell.t === 'n' ? 'NULL' : '';
|
||||
ta.style.height = Math.max(hgt, 30) + 'px';
|
||||
ta.focus();
|
||||
ta.select();
|
||||
editing = {td: td, i: i, j: j, ta: ta, cell: cell, col: col, nul: false};
|
||||
ta.addEventListener('keydown', function (ev) {
|
||||
if (ev.key === 'Enter' && !ev.shiftKey) { ev.preventDefault(); finish(editing, true); }
|
||||
else if (ev.key === 'Escape') { ev.preventDefault(); ev.stopPropagation(); finish(editing, false); }
|
||||
else if (ev.key === 'Tab') {
|
||||
ev.preventDefault();
|
||||
var cur = editing, next = ev.shiftKey ? cur.td.previousElementSibling : cur.td.nextElementSibling;
|
||||
finish(cur, true);
|
||||
if (next && next.classList.contains('ed')) edit(next);
|
||||
}
|
||||
});
|
||||
ta.addEventListener('blur', function () { setTimeout(function () { if (editing && editing.ta === ta && document.activeElement !== td.querySelector('.dbx-nullb')) finish(editing, true); }, 120); });
|
||||
td.querySelector('.dbx-nullb').onmousedown = function (ev) { ev.preventDefault(); editing.nul = true; finish(editing, true); };
|
||||
}
|
||||
function finish(e, keep) {
|
||||
if (!e || editing !== e) return;
|
||||
editing = null;
|
||||
var text = e.ta.value, cell = e.cell;
|
||||
var value = e.nul ? {t: 'n'} : {t: typeFor(text, cell.t, e.col.type), v: text};
|
||||
var same = e.nul ? cell.t === 'n' : (cell.t !== 'n' || text !== '') && text === (cell.v || '') && (cell.t === value.t || cell.t === 's' || value.t === 's');
|
||||
if (cell.t === 'n' && !e.nul && text === '') same = true;
|
||||
e.td.classList.remove('editing');
|
||||
if (!keep || same) { e.td.innerHTML = cellHTML(cell); return; }
|
||||
e.td.innerHTML = cellHTML(value);
|
||||
e.td.classList.add('saving');
|
||||
req('db.set', {table: S.cur, key: S.data.rows[e.i].k, column: e.col.name, value: value}).then(function (r) {
|
||||
e.td.classList.remove('saving');
|
||||
if (!r.ok) {
|
||||
e.td.innerHTML = cellHTML(cell);
|
||||
flash(e.td, 'bad');
|
||||
toast(r.error || 'не сохранилось', true);
|
||||
return;
|
||||
}
|
||||
var got = r.data.value;
|
||||
S.data.rows[e.i].v[e.j] = got;
|
||||
// A changed key column moves the row's key too.
|
||||
if (r.data.key !== undefined) S.data.rows[e.i].k = r.data.key;
|
||||
e.td.innerHTML = cellHTML(got);
|
||||
e.td.className = (got.t === 'i' || got.t === 'r' ? 'num' : '') + ' ed';
|
||||
flash(e.td, 'ok');
|
||||
});
|
||||
}
|
||||
function flash(td, kind) {
|
||||
td.classList.remove('flash-ok', 'flash-bad');
|
||||
void td.offsetWidth;
|
||||
td.classList.add('flash-' + kind);
|
||||
}
|
||||
|
||||
// ---- adding and deleting rows ----
|
||||
$('.dbx-add').onclick = function () {
|
||||
if (box.querySelector('tr.dbx-new')) return;
|
||||
var t = S.data.table, tbody = box.querySelector('.dbx-t tbody');
|
||||
var tr = document.createElement('tr');
|
||||
tr.className = 'dbx-new';
|
||||
var h = (t.key ? '<td class="dbx-ck"></td>' : '') + (t.key === 'rowid' && !t.alias ? '<td class="dbx-k">новая</td>' : '');
|
||||
t.columns.forEach(function (c) {
|
||||
h += '<td><input type="text" data-c="' + esc(c.name) + '" placeholder="' + esc(c.default ? 'по умолч.: ' + c.default : c.notnull ? 'обязательно' : 'NULL') + '"></td>';
|
||||
});
|
||||
tr.innerHTML = h;
|
||||
tbody.insertBefore(tr, tbody.firstChild);
|
||||
var bar = document.createElement('tr');
|
||||
bar.className = 'dbx-newbar';
|
||||
bar.innerHTML = '<td colspan="' + (t.columns.length + 2) + '"><button type="button" class="small dbx-addok">Добавить</button> <button type="button" class="btn ghost small dbx-addno">Отмена</button> <span class="muted">пустое поле - значение по умолчанию</span></td>';
|
||||
tbody.insertBefore(bar, tr.nextSibling);
|
||||
$('.dbx-grid').scrollTop = 0;
|
||||
tr.querySelector('input').focus();
|
||||
var done = function () { tr.remove(); bar.remove(); };
|
||||
bar.querySelector('.dbx-addno').onclick = done;
|
||||
var add = function () {
|
||||
var values = {};
|
||||
tr.querySelectorAll('input').forEach(function (inp) {
|
||||
if (inp.value === '') return;
|
||||
var c = t.columns.filter(function (x) { return x.name === inp.dataset.c; })[0];
|
||||
values[c.name] = {t: typeFor(inp.value, '', c.type), v: inp.value};
|
||||
});
|
||||
req('db.add', {table: S.cur, values: values}).then(function (r) {
|
||||
if (!r.ok) { toast(r.error || 'не добавилось', true); return; }
|
||||
toast('строка добавлена');
|
||||
var total = S.data.total + 1;
|
||||
show(S.cur, Math.floor((total - 1) / PAGE) * PAGE);
|
||||
});
|
||||
};
|
||||
bar.querySelector('.dbx-addok').onclick = add;
|
||||
tr.addEventListener('keydown', function (ev) {
|
||||
if (ev.key === 'Enter') { ev.preventDefault(); add(); }
|
||||
if (ev.key === 'Escape') { ev.preventDefault(); ev.stopPropagation(); done(); }
|
||||
});
|
||||
};
|
||||
$('.dbx-del').onclick = function () {
|
||||
var keys = [];
|
||||
S.sel.forEach(function (i) { keys.push(S.data.rows[i].k); });
|
||||
if (!keys.length || !confirm('Удалить строк: ' + keys.length + '?')) return;
|
||||
req('db.del', {table: S.cur, keys: keys}).then(function (r) {
|
||||
if (!r.ok) { toast(r.error || 'не удалилось', true); return; }
|
||||
toast('удалено строк: ' + r.data.deleted);
|
||||
show(S.cur, S.offset);
|
||||
});
|
||||
};
|
||||
|
||||
// ---- SQL ----
|
||||
function sqlTab() {
|
||||
S.cur = null;
|
||||
tabs();
|
||||
$('.dbx-grid').hidden = true;
|
||||
$('.dbx-tools').hidden = true;
|
||||
$('.dbx-sql').hidden = false;
|
||||
$('.dbx-q').focus();
|
||||
}
|
||||
function run() {
|
||||
var q = $('.dbx-q').value;
|
||||
if (!q.trim()) return;
|
||||
$('.dbx-sqlmsg').textContent = '';
|
||||
req('db.sql', {sql: q}).then(function (r) {
|
||||
var out = $('.dbx-sqlres');
|
||||
if (!r.ok) { out.innerHTML = ''; $('.dbx-sqlmsg').textContent = r.error || 'ошибка'; $('.dbx-sqlmsg').className = 'dbx-sqlmsg bad'; return; }
|
||||
$('.dbx-sqlmsg').className = 'muted dbx-sqlmsg';
|
||||
var d = r.data;
|
||||
if (d.columns) {
|
||||
$('.dbx-sqlmsg').textContent = 'строк: ' + d.rows.length + (d.more ? ' (показаны первые 500)' : '');
|
||||
var h = '<table class="dbx-t"><thead><tr>' + d.columns.map(function (c) { return '<th>' + esc(c) + '</th>'; }).join('') + '</tr></thead><tbody>';
|
||||
d.rows.forEach(function (row) {
|
||||
h += '<tr>' + row.map(function (c) { return '<td class="' + (c.t === 'i' || c.t === 'r' ? 'num' : '') + '">' + cellHTML(c) + '</td>'; }).join('') + '</tr>';
|
||||
});
|
||||
out.innerHTML = h + '</tbody></table>';
|
||||
} else {
|
||||
out.innerHTML = '';
|
||||
$('.dbx-sqlmsg').textContent = 'готово · изменено строк: ' + d.changed;
|
||||
loadTables(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
$('.dbx-run').onclick = run;
|
||||
$('.dbx-q').addEventListener('keydown', function (ev) {
|
||||
if (ev.key === 'Enter' && (ev.ctrlKey || ev.metaKey)) { ev.preventDefault(); run(); }
|
||||
});
|
||||
|
||||
function loadTables(keep) {
|
||||
return req('db.tables', {}).then(function (r) {
|
||||
if (!r.ok) return r;
|
||||
S.tables = r.data.tables;
|
||||
if (keep) { tabs(); return r; }
|
||||
if (S.tables.length) show(S.tables[0].name, 0); else sqlTab();
|
||||
return r;
|
||||
});
|
||||
}
|
||||
return loadTables(false);
|
||||
}
|
||||
|
||||
window.DBEdit = {open: open};
|
||||
})();
|
||||
@@ -0,0 +1,528 @@
|
||||
// Light previews of office files for the file manager, with no libraries:
|
||||
// docx, xlsx and ods are zip archives of XML, opened with the browser's own
|
||||
// DecompressionStream; csv and tsv are parsed here; an old binary .doc only
|
||||
// gives up its text. Each turns into plain HTML the page styles itself.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const esc = s => String(s).replace(/[&<>"]/g, c => ({'&': '&', '<': '<', '>': '>', '"': '"'}[c]));
|
||||
|
||||
// ---------- zip ----------
|
||||
// Reads the central directory; a file is inflated only when asked for.
|
||||
function openZip(buf) {
|
||||
const b = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
|
||||
const dv = new DataView(b.buffer, b.byteOffset, b.byteLength);
|
||||
let eocd = -1;
|
||||
for (let i = b.length - 22; i >= Math.max(0, b.length - 65557); i--) {
|
||||
if (dv.getUint32(i, true) === 0x06054b50) { eocd = i; break; }
|
||||
}
|
||||
if (eocd < 0) throw new Error('не zip-архив');
|
||||
const count = dv.getUint16(eocd + 10, true);
|
||||
let p = dv.getUint32(eocd + 16, true);
|
||||
const files = new Map();
|
||||
const dec = new TextDecoder();
|
||||
for (let n = 0; n < count && dv.getUint32(p, true) === 0x02014b50; n++) {
|
||||
const method = dv.getUint16(p + 10, true), size = dv.getUint32(p + 20, true);
|
||||
const nameLen = dv.getUint16(p + 28, true), extra = dv.getUint16(p + 30, true), comment = dv.getUint16(p + 32, true);
|
||||
const local = dv.getUint32(p + 42, true);
|
||||
files.set(dec.decode(b.subarray(p + 46, p + 46 + nameLen)), {method, size, local});
|
||||
p += 46 + nameLen + extra + comment;
|
||||
}
|
||||
async function bytes(name) {
|
||||
const f = files.get(name);
|
||||
if (!f) return null;
|
||||
const start = f.local + 30 + dv.getUint16(f.local + 26, true) + dv.getUint16(f.local + 28, true);
|
||||
const raw = b.subarray(start, start + f.size);
|
||||
if (f.method === 0) return raw;
|
||||
if (f.method !== 8) throw new Error('сжатие zip, которое браузер не знает');
|
||||
const out = new Blob([raw]).stream().pipeThrough(new DecompressionStream('deflate-raw'));
|
||||
return new Uint8Array(await new Response(out).arrayBuffer());
|
||||
}
|
||||
async function xml(name) {
|
||||
const raw = await bytes(name);
|
||||
return raw ? new DOMParser().parseFromString(new TextDecoder().decode(raw), 'application/xml') : null;
|
||||
}
|
||||
return {files, bytes, xml};
|
||||
}
|
||||
|
||||
// Children of an element by local name, whatever the namespace prefix.
|
||||
const kids = (el, name) => el ? Array.from(el.children).filter(c => c.localName === name) : [];
|
||||
const kid = (el, name) => kids(el, name)[0] || null;
|
||||
const attr = (el, name) => {
|
||||
if (!el) return null;
|
||||
for (const a of el.attributes) if (a.localName === name) return a.value;
|
||||
return null;
|
||||
};
|
||||
|
||||
// ---------- docx ----------
|
||||
async function docx(buf) {
|
||||
const z = openZip(buf);
|
||||
const doc = await z.xml('word/document.xml');
|
||||
if (!doc) throw new Error('в архиве нет word/document.xml');
|
||||
// Style ids to names, to know headings whatever the language of Word.
|
||||
const styles = {};
|
||||
const st = await z.xml('word/styles.xml');
|
||||
if (st) for (const s of st.getElementsByTagNameNS('*', 'style')) {
|
||||
styles[attr(s, 'styleId')] = (attr(kid(s, 'name'), 'val') || '').toLowerCase();
|
||||
}
|
||||
const body = doc.getElementsByTagNameNS('*', 'body')[0];
|
||||
return '<div class="dv-doc">' + blocks(body, styles) + '</div>';
|
||||
}
|
||||
function blocks(el, styles) {
|
||||
let html = '', list = false;
|
||||
for (const c of el ? el.children : []) {
|
||||
if (c.localName === 'p') {
|
||||
const pPr = kid(c, 'pPr');
|
||||
const isList = !!kid(pPr, 'numPr');
|
||||
if (isList && !list) { html += '<ul>'; list = true; }
|
||||
if (!isList && list) { html += '</ul>'; list = false; }
|
||||
html += para(c, pPr, styles, isList);
|
||||
} else {
|
||||
if (list) { html += '</ul>'; list = false; }
|
||||
if (c.localName === 'tbl') html += table(c, styles);
|
||||
else if (c.localName === 'sdt') html += blocks(kid(c, 'sdtContent'), styles);
|
||||
}
|
||||
}
|
||||
return html + (list ? '</ul>' : '');
|
||||
}
|
||||
function para(p, pPr, styles, isList) {
|
||||
const name = styles[attr(kid(pPr, 'pStyle'), 'val')] || '';
|
||||
const h = /^heading (\d)/.exec(name);
|
||||
const tag = isList ? 'li' : name === 'title' ? 'h1' : h ? 'h' + Math.min(6, +h[1] + 1) : 'p';
|
||||
const jc = attr(kid(pPr, 'jc'), 'val');
|
||||
const align = jc === 'center' ? 'center' : (jc === 'right' || jc === 'end') ? 'right' : (jc === 'both' ? 'justify' : '');
|
||||
const inner = runs(p);
|
||||
return '<' + tag + (align ? ' style="text-align:' + align + '"' : '') + '>' + (inner || ' ') + '</' + tag + '>';
|
||||
}
|
||||
function runs(el) {
|
||||
let html = '';
|
||||
for (const c of el.children) {
|
||||
if (c.localName === 'r') html += run(c);
|
||||
else if (c.localName === 'hyperlink' || c.localName === 'smartTag' || c.localName === 'ins' || c.localName === 'sdt' || c.localName === 'sdtContent' || c.localName === 'fldSimple') {
|
||||
const inner = runs(c);
|
||||
html += c.localName === 'hyperlink' ? '<u class="dv-link">' + inner + '</u>' : inner;
|
||||
}
|
||||
}
|
||||
return html;
|
||||
}
|
||||
function run(r) {
|
||||
const pr = kid(r, 'rPr');
|
||||
const on = n => { const e = kid(pr, n); return !!e && attr(e, 'val') !== '0' && attr(e, 'val') !== 'false'; };
|
||||
let t = '';
|
||||
for (const c of r.children) {
|
||||
if (c.localName === 't') t += esc(c.textContent);
|
||||
else if (c.localName === 'tab') t += ' ';
|
||||
else if (c.localName === 'br' || c.localName === 'cr') t += '<br>';
|
||||
}
|
||||
if (!t) return '';
|
||||
if (on('b')) t = '<b>' + t + '</b>';
|
||||
if (on('i')) t = '<i>' + t + '</i>';
|
||||
if (kid(pr, 'u') && attr(kid(pr, 'u'), 'val') !== 'none') t = '<u>' + t + '</u>';
|
||||
if (on('strike')) t = '<s>' + t + '</s>';
|
||||
// A size set on the run itself, as many documents do instead of styles.
|
||||
const sz = +attr(kid(pr, 'sz'), 'val');
|
||||
if (sz && sz !== 24) t = '<span style="font-size:' + Math.max(9, Math.min(36, sz / 2)) + 'pt">' + t + '</span>';
|
||||
const va = attr(kid(pr, 'vertAlign'), 'val');
|
||||
if (va === 'superscript') t = '<sup>' + t + '</sup>';
|
||||
else if (va === 'subscript') t = '<sub>' + t + '</sub>';
|
||||
return t;
|
||||
}
|
||||
function table(tbl, styles) {
|
||||
let html = '<div class="dv-scroll"><table class="dv-table">';
|
||||
for (const tr of kids(tbl, 'tr')) {
|
||||
html += '<tr>';
|
||||
for (const tc of kids(tr, 'tc')) {
|
||||
const span = attr(kid(kid(tc, 'tcPr'), 'gridSpan'), 'val');
|
||||
html += '<td' + (span > 1 ? ' colspan="' + span + '"' : '') + '>' + blocks(tc, styles) + '</td>';
|
||||
}
|
||||
html += '</tr>';
|
||||
}
|
||||
return html + '</table></div>';
|
||||
}
|
||||
|
||||
// ---------- tables: one grid per sheet ----------
|
||||
const MAX_ROWS = 2000, MAX_COLS = 60;
|
||||
function colName(i) { let s = ''; for (i++; i > 0; i = Math.floor((i - 1) / 26)) s = String.fromCharCode(65 + (i - 1) % 26) + s; return s; }
|
||||
function grid(rows, names) {
|
||||
let cols = names ? names.length : 0;
|
||||
for (const r of rows) cols = Math.max(cols, r.length);
|
||||
cols = Math.min(cols, MAX_COLS);
|
||||
const cut = rows.length > MAX_ROWS || rows.some(r => r.length > MAX_COLS);
|
||||
let html = '<div class="dv-scroll dv-grid"><table class="dv-sheet"><thead><tr><th></th>';
|
||||
for (let c = 0; c < cols; c++) html += '<th>' + esc(names && names[c] != null ? names[c] : colName(c)) + '</th>';
|
||||
html += '</tr></thead><tbody>';
|
||||
rows.slice(0, MAX_ROWS).forEach((r, i) => {
|
||||
html += '<tr><th>' + (i + 1) + '</th>';
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const v = r[c] == null ? '' : r[c];
|
||||
html += '<td' + (typeof v === 'number' ? ' class="n"' : '') + '>' + esc(v) + '</td>';
|
||||
}
|
||||
html += '</tr>';
|
||||
});
|
||||
html += '</tbody></table></div>';
|
||||
return {html, cut};
|
||||
}
|
||||
// Several sheets: tabs over the grid.
|
||||
function sheets(list) {
|
||||
if (!list.length) return '<p class="dv-note">в книге нет листов</p>';
|
||||
let html = '';
|
||||
if (list.length > 1) {
|
||||
html += '<div class="dv-tabs">' + list.map((s, i) => '<button type="button" data-sheet="' + i + '"' + (i ? '' : ' class="on"') + '>' + esc(s.name) + '</button>').join('') + '</div>';
|
||||
}
|
||||
list.forEach((s, i) => {
|
||||
const g = grid(s.rows, s.cols);
|
||||
html += '<div class="dv-pane" data-pane="' + i + '"' + (i ? ' hidden' : '') + '>' + (s.note ? '<p class="dv-note">' + esc(s.note) + '</p>' : '') + g.html +
|
||||
(g.cut ? '<p class="dv-note">показаны первые ' + MAX_ROWS + ' строк и ' + MAX_COLS + ' столбцов</p>' : '') + '</div>';
|
||||
});
|
||||
return html;
|
||||
}
|
||||
function bindTabs(root) {
|
||||
root.querySelectorAll('[data-sheet]').forEach(b => b.onclick = () => {
|
||||
root.querySelectorAll('[data-sheet]').forEach(x => x.classList.toggle('on', x === b));
|
||||
root.querySelectorAll('[data-pane]').forEach(p => p.hidden = p.dataset.pane !== b.dataset.sheet);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- xlsx ----------
|
||||
async function xlsx(buf) {
|
||||
const z = openZip(buf);
|
||||
const wb = await z.xml('xl/workbook.xml');
|
||||
if (!wb) throw new Error('в архиве нет xl/workbook.xml');
|
||||
const rels = {};
|
||||
const rx = await z.xml('xl/_rels/workbook.xml.rels');
|
||||
if (rx) for (const r of rx.getElementsByTagNameNS('*', 'Relationship')) rels[r.getAttribute('Id')] = r.getAttribute('Target');
|
||||
const shared = [];
|
||||
const ss = await z.xml('xl/sharedStrings.xml');
|
||||
if (ss) for (const si of ss.getElementsByTagNameNS('*', 'si')) {
|
||||
shared.push(Array.from(si.getElementsByTagNameNS('*', 't')).filter(t => t.parentElement.localName !== 'rPh').map(t => t.textContent).join(''));
|
||||
}
|
||||
const list = [];
|
||||
for (const s of wb.getElementsByTagNameNS('*', 'sheet')) {
|
||||
let target = rels[attr(s, 'id')] || '';
|
||||
target = target.startsWith('/') ? target.slice(1) : 'xl/' + target.replace(/^\.\//, '');
|
||||
const doc = await z.xml(target);
|
||||
list.push({name: s.getAttribute('name') || '?', rows: doc ? xlsxRows(doc, shared) : []});
|
||||
}
|
||||
return sheets(list);
|
||||
}
|
||||
function xlsxRows(doc, shared) {
|
||||
const rows = [];
|
||||
for (const row of doc.getElementsByTagNameNS('*', 'row')) {
|
||||
const ri = (+row.getAttribute('r') || rows.length + 1) - 1;
|
||||
if (ri >= MAX_ROWS) break;
|
||||
const out = rows[ri] = rows[ri] || [];
|
||||
let next = 0;
|
||||
for (const c of kids(row, 'c')) {
|
||||
const ref = /^([A-Z]+)/.exec(c.getAttribute('r') || '');
|
||||
let ci = next;
|
||||
if (ref) { ci = 0; for (const ch of ref[1]) ci = ci * 26 + ch.charCodeAt(0) - 64; ci--; }
|
||||
next = ci + 1;
|
||||
if (ci >= MAX_COLS + 1) continue;
|
||||
const t = c.getAttribute('t'), v = kid(c, 'v');
|
||||
let val = v ? v.textContent : '';
|
||||
if (t === 's') val = shared[+val] || '';
|
||||
else if (t === 'inlineStr') val = Array.from(c.getElementsByTagNameNS('*', 't')).map(x => x.textContent).join('');
|
||||
else if (t === 'b') val = val === '1' ? 'TRUE' : 'FALSE';
|
||||
else if (t !== 'str' && t !== 'e' && val !== '' && !isNaN(val)) val = +val;
|
||||
out[ci] = val;
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < rows.length; i++) rows[i] = rows[i] || [];
|
||||
return rows;
|
||||
}
|
||||
|
||||
// ---------- ods / odt ----------
|
||||
async function ods(buf) {
|
||||
const z = openZip(buf);
|
||||
const doc = await z.xml('content.xml');
|
||||
if (!doc) throw new Error('в архиве нет content.xml');
|
||||
const list = [];
|
||||
for (const t of doc.getElementsByTagNameNS('*', 'table')) {
|
||||
if (t.parentElement && t.parentElement.localName === 'table-cell') continue;
|
||||
const rows = [];
|
||||
for (const r of t.getElementsByTagNameNS('*', 'table-row')) {
|
||||
const cells = [];
|
||||
for (const c of r.children) {
|
||||
if (c.localName !== 'table-cell' && c.localName !== 'covered-table-cell') continue;
|
||||
const rep = Math.min(+attr(c, 'number-columns-repeated') || 1, MAX_COLS + 1 - cells.length);
|
||||
const num = attr(c, 'value');
|
||||
const text = Array.from(c.getElementsByTagNameNS('*', 'p')).map(p => p.textContent).join('\n');
|
||||
const val = attr(c, 'value-type') === 'float' && num != null ? +num : text;
|
||||
for (let i = 0; i < rep; i++) cells.push(val);
|
||||
if (cells.length > MAX_COLS) break;
|
||||
}
|
||||
while (cells.length && cells[cells.length - 1] === '') cells.pop();
|
||||
const rep = Math.min(+attr(r, 'number-rows-repeated') || 1, MAX_ROWS + 1 - rows.length);
|
||||
for (let i = 0; i < rep; i++) rows.push(cells);
|
||||
if (rows.length > MAX_ROWS) break;
|
||||
}
|
||||
while (rows.length && !rows[rows.length - 1].length) rows.pop();
|
||||
list.push({name: attr(t, 'name') || '?', rows});
|
||||
}
|
||||
return sheets(list);
|
||||
}
|
||||
async function odt(buf) {
|
||||
const z = openZip(buf);
|
||||
const doc = await z.xml('content.xml');
|
||||
const body = doc && doc.getElementsByTagNameNS('*', 'text')[0];
|
||||
if (!body) throw new Error('в архиве нет текста');
|
||||
// Runs of spaces, tabs and breaks are elements of their own in ODF.
|
||||
const inline = el => {
|
||||
let html = '';
|
||||
for (const n of el.childNodes) {
|
||||
if (n.nodeType === 3) html += esc(n.nodeValue);
|
||||
else if (n.localName === 's') html += ' '.repeat(+attr(n, 'c') || 1);
|
||||
else if (n.localName === 'tab') html += ' ';
|
||||
else if (n.localName === 'line-break') html += '<br>';
|
||||
else if (n.nodeType === 1) html += inline(n);
|
||||
}
|
||||
return html;
|
||||
};
|
||||
const walk = el => {
|
||||
let html = '';
|
||||
for (const c of el.children) {
|
||||
if (c.localName === 'h') { const l = Math.min(6, (+attr(c, 'outline-level') || 1) + 1); html += '<h' + l + '>' + inline(c) + '</h' + l + '>'; }
|
||||
else if (c.localName === 'p') html += '<p>' + (inline(c) || ' ') + '</p>';
|
||||
else if (c.localName === 'list') html += '<ul>' + kids(c, 'list-item').map(li => '<li>' + walk(li) + '</li>').join('') + '</ul>';
|
||||
else if (c.localName === 'table') html += '<div class="dv-scroll"><table class="dv-table">' + Array.from(c.getElementsByTagNameNS('*', 'table-row')).map(r =>
|
||||
'<tr>' + kids(r, 'table-cell').map(td => '<td>' + walk(td) + '</td>').join('') + '</tr>').join('') + '</table></div>';
|
||||
else if (c.localName === 'section') html += walk(c);
|
||||
}
|
||||
return html;
|
||||
};
|
||||
return '<div class="dv-doc">' + walk(body) + '</div>';
|
||||
}
|
||||
|
||||
// ---------- csv / tsv ----------
|
||||
function csv(text, tab) {
|
||||
const first = text.slice(0, text.indexOf('\n') >>> 0 || text.length);
|
||||
const sep = tab ? '\t' : (first.split(';').length > first.split(',').length ? ';' : ',');
|
||||
const rows = [];
|
||||
let row = [], f = '', q = false;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const c = text[i];
|
||||
if (q) {
|
||||
if (c === '"') { if (text[i + 1] === '"') { f += '"'; i++; } else q = false; }
|
||||
else f += c;
|
||||
} else if (c === '"' && f === '') q = true;
|
||||
else if (c === sep) { row.push(f); f = ''; }
|
||||
else if (c === '\n' || c === '\r') {
|
||||
if (c === '\r' && text[i + 1] === '\n') i++;
|
||||
row.push(f); f = ''; rows.push(row); row = [];
|
||||
if (rows.length > MAX_ROWS) break;
|
||||
} else f += c;
|
||||
}
|
||||
if (f !== '' || row.length) { row.push(f); rows.push(row); }
|
||||
return sheets([{name: '', rows: rows.map(r => r.map(v => v !== '' && !isNaN(v) && /^-?[\d.]+$/.test(v.trim()) ? +v : v))}]);
|
||||
}
|
||||
|
||||
// ---------- old .doc ----------
|
||||
// Word 97-2003 is a binary format; without a full parser all that can be
|
||||
// shown is the text, picked out of the document's WordDocument stream as
|
||||
// the runs that read as words.
|
||||
function cfbStream(b, want) {
|
||||
const dv = new DataView(b.buffer, b.byteOffset, b.byteLength);
|
||||
if (dv.getUint32(0, true) !== 0xE011CFD0) return null;
|
||||
const ss = 1 << dv.getUint16(0x1E, true);
|
||||
const sector = n => 512 + n * ss;
|
||||
const fat = [];
|
||||
const addFat = n => { const o = sector(n); for (let i = 0; i < ss / 4; i++) fat.push(dv.getUint32(o + i * 4, true)); };
|
||||
for (let i = 0; i < 109; i++) { const n = dv.getUint32(0x4C + i * 4, true); if (n < 0xFFFFFFFA) addFat(n); }
|
||||
for (let n = dv.getUint32(0x44, true), k = 0; n < 0xFFFFFFFA && k < 1000; k++) {
|
||||
const o = sector(n);
|
||||
for (let i = 0; i < ss / 4 - 1; i++) { const m = dv.getUint32(o + i * 4, true); if (m < 0xFFFFFFFA) addFat(m); }
|
||||
n = dv.getUint32(o + ss - 4, true);
|
||||
}
|
||||
const chain = (start, size) => {
|
||||
const out = new Uint8Array(size);
|
||||
let n = start, at = 0;
|
||||
for (let guard = 0; n < 0xFFFFFFFA && at < size && guard < 1e6; guard++) {
|
||||
const o = sector(n), len = Math.min(ss, size - at);
|
||||
if (o + len > b.length) break;
|
||||
out.set(b.subarray(o, o + len), at);
|
||||
at += len;
|
||||
n = fat[n];
|
||||
}
|
||||
return out.subarray(0, at);
|
||||
};
|
||||
const dirStart = dv.getUint32(0x30, true);
|
||||
const dir = chain(dirStart, ss * 64);
|
||||
const dd = new DataView(dir.buffer, dir.byteOffset, dir.byteLength);
|
||||
for (let o = 0; o + 128 <= dir.length; o += 128) {
|
||||
const nameLen = dd.getUint16(o + 0x40, true);
|
||||
let name = '';
|
||||
for (let i = 0; i + 2 < nameLen; i += 2) name += String.fromCharCode(dd.getUint16(o + i, true));
|
||||
if (name === want && dd.getUint8(o + 0x42) === 2) return chain(dd.getUint32(o + 0x74, true), dd.getUint32(o + 0x78, true));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function doc(buf) {
|
||||
const all = new Uint8Array(buf);
|
||||
const b = cfbStream(all, 'WordDocument') || all;
|
||||
const ok = c => c === 9 || c === 10 || c === 13 || (c >= 32 && c < 127) || (c >= 0xA0 && c <= 0x24F) || (c >= 0x400 && c <= 0x4FF) || (c >= 0x2010 && c <= 0x2026) || c === 0x2116;
|
||||
const pieces = [];
|
||||
let cur = '';
|
||||
const flush = min => {
|
||||
const letters = (cur.match(/[A-Za-zА-Яа-яЁё0-9]/g) || []).length;
|
||||
if (cur.trim().length >= min && letters >= cur.trim().length * 0.5) pieces.push(cur);
|
||||
cur = '';
|
||||
};
|
||||
for (let i = 0; i + 1 < b.length; i += 2) {
|
||||
const c = b[i] | (b[i + 1] << 8);
|
||||
if (ok(c)) cur += String.fromCharCode(c); else flush(3);
|
||||
}
|
||||
flush(4);
|
||||
let text = pieces.join('\n');
|
||||
if (text.length < 40) { // a document that keeps its text 8-bit
|
||||
const p8 = [];
|
||||
for (let i = 0; i < b.length; i++) {
|
||||
const c = b[i];
|
||||
if (c === 9 || c === 10 || c === 13 || (c >= 32 && c < 127)) cur += String.fromCharCode(c);
|
||||
else { if (cur.trim().length >= 8 && (cur.match(/[A-Za-z]/g) || []).length >= cur.length * 0.5) p8.push(cur); cur = ''; }
|
||||
}
|
||||
text = p8.join('\n');
|
||||
}
|
||||
if (!text.trim()) throw new Error('текст не нашёлся');
|
||||
return '<p class="dv-note">старый формат .doc: показан только текст, без оформления</p><div class="dv-doc">' +
|
||||
text.split(/\r\n|\r|\n|\x07/).filter(l => l.trim()).map(l => '<p>' + esc(l) + '</p>').join('') + '</div>';
|
||||
}
|
||||
|
||||
// ---------- sqlite ----------
|
||||
// Reads the database file itself: the b-tree of sqlite_master for the
|
||||
// tables, then each table's b-tree for its first rows. No SQL engine.
|
||||
function sqlite(buf) {
|
||||
const b = new Uint8Array(buf);
|
||||
if (new TextDecoder().decode(b.subarray(0, 15)) !== 'SQLite format 3') throw new Error('не база SQLite');
|
||||
const dv = new DataView(b.buffer, b.byteOffset, b.byteLength);
|
||||
let ps = dv.getUint16(16);
|
||||
if (ps === 1) ps = 65536;
|
||||
const usable = ps - b[20];
|
||||
const enc = dv.getUint32(56);
|
||||
const dec = new TextDecoder(enc === 2 ? 'utf-16le' : enc === 3 ? 'utf-16be' : 'utf-8');
|
||||
const page = n => (n - 1) * ps;
|
||||
|
||||
function varint(o) {
|
||||
let v = 0;
|
||||
for (let i = 0; i < 9; i++) {
|
||||
const c = b[o + i];
|
||||
if (i === 8) return [v * 256 + c, o + 9];
|
||||
v = v * 128 + (c & 0x7f);
|
||||
if (!(c & 0x80)) return [v, o + i + 1];
|
||||
}
|
||||
}
|
||||
// A cell's payload, gathered from its overflow pages when it spills.
|
||||
function payload(o, size) {
|
||||
const max = usable - 35;
|
||||
if (size <= max) return b.subarray(o, o + size);
|
||||
const min = Math.floor((usable - 12) * 32 / 255) - 23;
|
||||
let local = min + (size - min) % (usable - 4);
|
||||
if (local > max) local = min;
|
||||
const out = new Uint8Array(size);
|
||||
out.set(b.subarray(o, o + local));
|
||||
let at = local, next = dv.getUint32(o + local);
|
||||
for (let guard = 0; next && at < size && guard < 1e6; guard++) {
|
||||
const po = page(next), len = Math.min(usable - 4, size - at);
|
||||
out.set(b.subarray(po + 4, po + 4 + len), at);
|
||||
at += len;
|
||||
next = dv.getUint32(po);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function record(p) {
|
||||
const pv = new DataView(p.buffer, p.byteOffset, p.byteLength);
|
||||
let [hlen, o] = vi(p, 0);
|
||||
const types = [];
|
||||
while (o < hlen) { const r = vi(p, o); types.push(r[0]); o = r[1]; }
|
||||
let d = hlen;
|
||||
return types.map(t => {
|
||||
let v = null;
|
||||
if (t >= 1 && t <= 6) {
|
||||
const n = [0, 1, 2, 3, 4, 6, 8][t];
|
||||
let x = 0;
|
||||
for (let i = 0; i < n; i++) x = x * 256 + p[d + i];
|
||||
if (p[d] & 0x80) x -= Math.pow(2, 8 * n);
|
||||
v = n === 8 && Math.abs(x) > Number.MAX_SAFE_INTEGER ? pv.getBigInt64(d).toString() : x;
|
||||
d += n;
|
||||
} else if (t === 7) { v = pv.getFloat64(d); d += 8; }
|
||||
else if (t === 8) v = 0;
|
||||
else if (t === 9) v = 1;
|
||||
else if (t >= 12 && t % 2 === 0) { const n = (t - 12) / 2; v = '[blob ' + n + ' B]'; d += n; }
|
||||
else if (t >= 13) { const n = (t - 13) / 2; v = dec.decode(p.subarray(d, d + n)); d += n; }
|
||||
return v;
|
||||
});
|
||||
}
|
||||
// varint over a payload buffer rather than the file
|
||||
function vi(p, o) {
|
||||
let v = 0;
|
||||
for (let i = 0; i < 9; i++) {
|
||||
const c = p[o + i];
|
||||
if (i === 8) return [v * 256 + c, o + 9];
|
||||
v = v * 128 + (c & 0x7f);
|
||||
if (!(c & 0x80)) return [v, o + i + 1];
|
||||
}
|
||||
}
|
||||
// Walks a table b-tree in rowid order, up to limit rows; also counts all.
|
||||
function table(root, limit) {
|
||||
const rows = [];
|
||||
let count = 0;
|
||||
const walk = (n, depth) => {
|
||||
if (depth > 40) return;
|
||||
const h = page(n) + (n === 1 ? 100 : 0);
|
||||
const type = b[h], cells = dv.getUint16(h + 3);
|
||||
const ptrs = h + (type === 0x05 ? 12 : 8);
|
||||
if (type === 0x0d) {
|
||||
count += cells;
|
||||
for (let i = 0; i < cells && rows.length < limit; i++) {
|
||||
let o = page(n) + dv.getUint16(ptrs + i * 2), size, rowid;
|
||||
[size, o] = varint(o);
|
||||
[rowid, o] = varint(o);
|
||||
rows.push({rowid, values: record(payload(o, size))});
|
||||
}
|
||||
} else if (type === 0x05) {
|
||||
for (let i = 0; i < cells; i++) walk(dv.getUint32(page(n) + dv.getUint16(ptrs + i * 2)), depth + 1);
|
||||
walk(dv.getUint32(h + 8), depth + 1);
|
||||
}
|
||||
};
|
||||
walk(root, 0);
|
||||
return {rows, count};
|
||||
}
|
||||
// Column names out of CREATE TABLE; an INTEGER PRIMARY KEY is the rowid.
|
||||
function columns(sql) {
|
||||
const m = /\(([\s\S]*)\)/.exec(sql || '');
|
||||
if (!m) return {names: [], rowidCol: -1};
|
||||
const parts = [];
|
||||
let depth = 0, cur = '';
|
||||
for (const c of m[1]) {
|
||||
if (c === '(') depth++;
|
||||
if (c === ')') depth--;
|
||||
if (c === ',' && !depth) { parts.push(cur); cur = ''; } else cur += c;
|
||||
}
|
||||
parts.push(cur);
|
||||
const names = [];
|
||||
let rowidCol = -1;
|
||||
for (const p of parts) {
|
||||
const t = p.trim();
|
||||
if (!t || /^(constraint|primary\s+key|unique|check|foreign\s+key)\b/i.test(t)) continue;
|
||||
const nm = /^("(?:[^"]|"")+"|`[^`]+`|\[[^\]]+\]|\S+)/.exec(t)[1].replace(/^["`\[]|["`\]]$/g, '');
|
||||
if (/^\S+\s+integer\s+primary\s+key\b/i.test(t.replace(/^("(?:[^"]|"")+"|`[^`]+`|\[[^\]]+\])/, 'x'))) rowidCol = names.length;
|
||||
names.push(nm);
|
||||
}
|
||||
return {names, rowidCol};
|
||||
}
|
||||
|
||||
const master = table(1, 100000).rows.map(r => ({type: r.values[0], name: r.values[1], root: r.values[3], sql: r.values[4] || ''}));
|
||||
const list = [];
|
||||
for (const t of master.filter(x => x.type === 'table' && x.root).sort((a, c) => String(a.name).localeCompare(String(c.name)))) {
|
||||
if (/without\s+rowid/i.test(t.sql)) { list.push({name: t.name, rows: [], note: 'таблица WITHOUT ROWID - её строки здесь не показываются'}); continue; }
|
||||
const {names, rowidCol} = columns(t.sql);
|
||||
let data;
|
||||
try { data = table(t.root, MAX_ROWS); } catch (e) { list.push({name: t.name, rows: [], note: 'не удалось прочитать: ' + e.message}); continue; }
|
||||
const rows = data.rows.map(r => { const v = r.values.slice(); if (rowidCol >= 0 && v[rowidCol] == null) v[rowidCol] = r.rowid; return v; });
|
||||
list.push({name: t.name, cols: names, rows, note: data.count + ' ' + (data.count % 10 === 1 && data.count % 100 !== 11 ? 'строка' : data.count % 10 >= 2 && data.count % 10 <= 4 && (data.count % 100 < 12 || data.count % 100 > 14) ? 'строки' : 'строк') + (data.count > rows.length ? ', показаны первые ' + rows.length : '')});
|
||||
}
|
||||
if (!list.length) return '<p class="dv-note">в базе нет таблиц</p>';
|
||||
return sheets(list);
|
||||
}
|
||||
|
||||
window.DocView = {docx, xlsx, ods, odt, csv, doc, sqlite, bindTabs};
|
||||
})();
|
||||
@@ -0,0 +1,118 @@
|
||||
// The PIN lock: in a new browser tab the panel asks for the admin's PIN
|
||||
// before showing anything. The tab remembers it was unlocked (sessionStorage
|
||||
// lives as long as the tab, through reloads, and a new tab starts empty), so
|
||||
// a reload does not ask again. Loaded in <head>, before the page shows.
|
||||
// A device can be set to trust the PIN for a while: for that many minutes
|
||||
// after it was entered, new tabs there open without asking (localStorage,
|
||||
// this browser only).
|
||||
(function () {
|
||||
'use strict';
|
||||
var KEY = 'lpra.pin.ok';
|
||||
var ok = false;
|
||||
try { ok = sessionStorage.getItem(KEY) === '1'; } catch (e) { /* no storage: ask */ }
|
||||
if (ok) return;
|
||||
try {
|
||||
if (Date.now() < Number(localStorage.getItem('lpra.pin.until') || 0)) {
|
||||
sessionStorage.setItem(KEY, '1');
|
||||
return;
|
||||
}
|
||||
} catch (e) { /* ask */ }
|
||||
var root = document.documentElement;
|
||||
root.classList.add('pin-wait');
|
||||
function release() { root.classList.remove('pin-wait'); }
|
||||
function remember() { try { sessionStorage.setItem(KEY, '1'); } catch (e) { /* asked again next load */ } }
|
||||
// After the PIN itself: no asking on this device for the minutes it trusts.
|
||||
function trust() {
|
||||
try {
|
||||
var min = Number(localStorage.getItem('lpra.pin.grace') || 0);
|
||||
if (min > 0) localStorage.setItem('lpra.pin.until', String(Date.now() + min * 60000));
|
||||
} catch (e) { /* asks every tab */ }
|
||||
}
|
||||
|
||||
fetch('/api/panel/pin', {cache: 'no-store'})
|
||||
.then(function (r) { return r.ok ? r.json() : {pin: false}; })
|
||||
.then(function (d) {
|
||||
if (!d.pin) { remember(); release(); return; }
|
||||
if (document.body) lock(); else document.addEventListener('DOMContentLoaded', lock);
|
||||
})
|
||||
.catch(release);
|
||||
|
||||
function lock() {
|
||||
var LOCK = '<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="11" width="16" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg>';
|
||||
var BACK = '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 5H9l-6 7 6 7h12a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1z"/><path d="m16 9-5 6M11 9l5 6"/></svg>';
|
||||
var box = document.createElement('div');
|
||||
box.className = 'pinlock';
|
||||
box.setAttribute('role', 'dialog');
|
||||
box.setAttribute('aria-modal', 'true');
|
||||
box.setAttribute('aria-label', 'Ввод PIN-кода');
|
||||
var keys = '';
|
||||
['1', '2', '3', '4', '5', '6', '7', '8', '9', 'out', '0', 'back'].forEach(function (k) {
|
||||
var label = k === 'back' ? ' aria-label="Стереть">' + BACK : k === 'out' ? ' title="Выйти из панели">Выйти' : '>' + k;
|
||||
keys += '<button type="button" data-k="' + k + '"' + label + '</button>';
|
||||
});
|
||||
box.innerHTML =
|
||||
'<div class="pin-card">' +
|
||||
'<div class="pin-ico">' + LOCK + '</div>' +
|
||||
'<div class="pin-title">Введите PIN-код</div>' +
|
||||
'<div class="pin-dots"><i></i><i></i><i></i><i></i></div>' +
|
||||
'<div class="pin-msg" aria-live="polite"></div>' +
|
||||
'<div class="pin-pad">' + keys + '</div>' +
|
||||
'<form method="post" action="/logout" class="pin-out" hidden></form>' +
|
||||
'</div>';
|
||||
document.body.appendChild(box);
|
||||
requestAnimationFrame(function () { box.classList.add('on'); });
|
||||
var dots = box.querySelectorAll('.pin-dots i'), msg = box.querySelector('.pin-msg'), card = box.querySelector('.pin-card');
|
||||
var code = '', busy = false;
|
||||
function show() {
|
||||
for (var i = 0; i < 4; i++) dots[i].classList.toggle('on', i < code.length);
|
||||
}
|
||||
function press(k) {
|
||||
if (k === 'out') { box.querySelector('.pin-out').submit(); return; }
|
||||
if (busy) return;
|
||||
if (k === 'back') { code = code.slice(0, -1); show(); return; }
|
||||
if (code.length >= 4) return;
|
||||
code += k;
|
||||
msg.textContent = '';
|
||||
card.classList.remove('bad');
|
||||
show();
|
||||
if (code.length === 4) check();
|
||||
}
|
||||
function check() {
|
||||
busy = true;
|
||||
fetch('/api/panel/pin/check', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({pin: code})})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (r) {
|
||||
if (r.ok) {
|
||||
remember();
|
||||
trust();
|
||||
box.classList.add('done');
|
||||
release();
|
||||
setTimeout(function () { box.remove(); }, 320);
|
||||
document.removeEventListener('keydown', onKey, true);
|
||||
return;
|
||||
}
|
||||
if (r.out) { location.href = '/login'; return; }
|
||||
card.classList.remove('bad');
|
||||
void card.offsetWidth; // restart the shake
|
||||
card.classList.add('bad');
|
||||
msg.textContent = r.error + (r.left ? ' - осталось попыток: ' + r.left : '');
|
||||
setTimeout(function () { code = ''; show(); busy = false; }, 380);
|
||||
})
|
||||
.catch(function () { msg.textContent = 'нет связи с сервером'; code = ''; show(); busy = false; });
|
||||
}
|
||||
box.querySelector('.pin-pad').addEventListener('click', function (ev) {
|
||||
var b = ev.target.closest('button');
|
||||
if (b) press(b.dataset.k);
|
||||
});
|
||||
// Typed digits, ahead of anything on the page (the terminal included).
|
||||
function onKey(ev) {
|
||||
if (/^[0-9]$/.test(ev.key)) press(ev.key);
|
||||
else if (ev.key === 'Backspace') press('back');
|
||||
else if (ev.key !== 'Tab') return;
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
}
|
||||
document.addEventListener('keydown', onKey, true);
|
||||
if (document.activeElement && document.activeElement.blur) document.activeElement.blur();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,54 @@
|
||||
// Mac-like scrollbars: a scrolled element shows its thin scrollbar, and so
|
||||
// does one whose edge the pointer is at; after a second at rest it fades out.
|
||||
// Elements with nothing to scroll never show one. The look is in app.css.
|
||||
(function () {
|
||||
'use strict';
|
||||
var live = new Map(); // element -> {v: shown 0..1, until: ms to stay}
|
||||
var raf = 0;
|
||||
function el(t) { return t === document || t === window ? document.documentElement : t; }
|
||||
function scrollable(e) {
|
||||
return e.scrollHeight > e.clientHeight + 1 || e.scrollWidth > e.clientWidth + 1;
|
||||
}
|
||||
function show(e, ms) {
|
||||
if (!e || !e.style || !scrollable(e)) return;
|
||||
var s = live.get(e) || {v: 0, until: 0};
|
||||
s.until = Math.max(s.until, performance.now() + ms);
|
||||
live.set(e, s);
|
||||
if (!raf) raf = setTimeout(tick, 16);
|
||||
}
|
||||
var last = 0;
|
||||
// A timer, not animation frames: those stop in a tab out of sight, and the
|
||||
// bar would stay shown.
|
||||
function tick() {
|
||||
var now = performance.now();
|
||||
var dt = Math.min(64, now - (last || now - 16));
|
||||
last = now;
|
||||
live.forEach(function (s, e) {
|
||||
// In fast (about 0.12 s), out slowly (about 0.4 s).
|
||||
var want = now < s.until ? 1 : 0;
|
||||
s.v = want ? Math.min(1, s.v + dt / 120) : Math.max(0, s.v - dt / 400);
|
||||
e.style.setProperty('--sb', s.v.toFixed(3));
|
||||
if (!want && s.v === 0) { e.style.removeProperty('--sb'); live.delete(e); }
|
||||
});
|
||||
raf = live.size ? setTimeout(tick, 16) : 0;
|
||||
if (!raf) last = 0;
|
||||
}
|
||||
document.addEventListener('scroll', function (ev) { show(el(ev.target), 900); }, {capture: true, passive: true});
|
||||
// The pointer at a scrollable element's right or bottom edge shows its bar.
|
||||
var moved = null;
|
||||
document.addEventListener('pointermove', function (ev) {
|
||||
if (ev.pointerType !== 'mouse') return;
|
||||
if (!moved) requestAnimationFrame(function () {
|
||||
var m = moved; moved = null;
|
||||
for (var e = m.target; e && e.nodeType === 1; e = e.parentElement) {
|
||||
if (!scrollable(e)) continue;
|
||||
var st = getComputedStyle(e);
|
||||
if (!/(auto|scroll)/.test(st.overflowY + st.overflowX) && e !== document.documentElement) continue;
|
||||
var r = e === document.documentElement ? {right: innerWidth, bottom: innerHeight} : e.getBoundingClientRect();
|
||||
if (m.x >= r.right - 14 || m.y >= r.bottom - 14) show(e, 700);
|
||||
break;
|
||||
}
|
||||
});
|
||||
moved = {target: ev.target, x: ev.clientX, y: ev.clientY};
|
||||
}, {passive: true});
|
||||
})();
|
||||
@@ -0,0 +1,858 @@
|
||||
// Terminal page: shells on one or more machines, in tabs. With one tab the
|
||||
// page is a plain terminal and a faint "+" in the corner; a second tab brings
|
||||
// the tab strip in. On a computer a tab can be dragged onto the current one
|
||||
// to share it - up to four terminals, split side by side or one above the
|
||||
// other - and a terminal dragged out of it back to the strip. Splits resize
|
||||
// by their gutters; a tab is renamed by a double click on its title.
|
||||
(function () {
|
||||
'use strict';
|
||||
const PAGE = window.TERM_PAGE;
|
||||
const touch = matchMedia('(pointer: coarse)').matches;
|
||||
const desk = !touch; // drag and drop, splits: computers only
|
||||
if (touch) document.body.classList.add('touch');
|
||||
const $ = id => document.getElementById(id);
|
||||
const statusEl = $('status'), area = $('area'), tablist = $('tablist'), addBtn = $('tabadd'),
|
||||
floatAdd = $('floatadd'), addMenu = $('addmenu'), hint = $('hint'), mark = $('tabmark');
|
||||
const base = '/api/panel/term/';
|
||||
const GAP = 6;
|
||||
const X = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>';
|
||||
|
||||
function bytesToB64(bytes) {
|
||||
let bin = '';
|
||||
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
|
||||
return btoa(bin);
|
||||
}
|
||||
function b64ToBytes(b64) {
|
||||
const bin = atob(b64), u = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) u[i] = bin.charCodeAt(i);
|
||||
return u;
|
||||
}
|
||||
|
||||
// ---------- copy and paste by keys ----------
|
||||
// Off a Mac, where Cmd does it, Ctrl+C copies when there is a selection and
|
||||
// interrupts as ever when there is none; Ctrl+Shift+C always copies (and is
|
||||
// kept from the browser, whose developer tools it would open); Ctrl+V and
|
||||
// Ctrl+Shift+V paste. Keys go by position, so a Russian layout works too.
|
||||
const mac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
|
||||
function copyText(text) {
|
||||
if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(text);
|
||||
// Plain http: the old way, through a field that is there for a moment.
|
||||
const ta = document.createElement('textarea'), back = document.activeElement;
|
||||
ta.value = text;
|
||||
ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
const ok = document.execCommand('copy');
|
||||
ta.remove();
|
||||
if (back && back.focus) back.focus();
|
||||
return ok ? Promise.resolve() : Promise.reject(new Error('нет доступа к буферу'));
|
||||
}
|
||||
function clipKeys(s, ev) {
|
||||
if (mac || !ev.ctrlKey || ev.altKey || ev.metaKey) return true;
|
||||
const c = ev.code === 'KeyC', v = ev.code === 'KeyV';
|
||||
if (!c && !v) return true;
|
||||
if (c && !ev.shiftKey && !s.term.hasSelection()) return true; // ^C to the shell
|
||||
// From here the key is ours: xterm does not turn it into a control code.
|
||||
if (ev.type === 'keydown' && c) {
|
||||
ev.preventDefault();
|
||||
if (s.term.hasSelection()) {
|
||||
const text = s.term.getSelection();
|
||||
s.term.clearSelection();
|
||||
copyText(text).then(() => flashStatus('скопировано', 'st-ok'), e => flashStatus('не скопировалось: ' + e.message, 'st-err'));
|
||||
}
|
||||
}
|
||||
// Ctrl+V: the browser's paste goes on into the terminal's text field.
|
||||
return false;
|
||||
}
|
||||
function flashStatus(t, cls) {
|
||||
statusEl.textContent = t;
|
||||
statusEl.className = cls;
|
||||
clearTimeout(flashStatus.t);
|
||||
flashStatus.t = setTimeout(showStatus, 1500);
|
||||
}
|
||||
|
||||
// ---------- one shell ----------
|
||||
class Session {
|
||||
// ssh, when given, is where the agent connects by SSH for this shell:
|
||||
// {host, port, user} and the secret to sign in with, sent once on open.
|
||||
constructor(agent, ssh) {
|
||||
this.agent = agent;
|
||||
this.ssh = ssh || null;
|
||||
this.sid = null; this.closed = false; this.connected = false;
|
||||
this.state = 'wait'; this.text = 'подключение…';
|
||||
this.inSeq = 0; this.inBusy = false; this.inQueue = [];
|
||||
this.el = document.createElement('div');
|
||||
this.el.className = 'pane';
|
||||
this.el.innerHTML = '<div class="pane-head"><span class="dot wait"></span><span class="pane-title"></span>' +
|
||||
'<button type="button" class="pane-x" title="Закрыть терминал" aria-label="Закрыть терминал">' + X + '</button></div>' +
|
||||
'<div class="pane-body"></div>';
|
||||
this.el.querySelector('.pane-title').textContent = who(this);
|
||||
this.el.querySelector('.pane-title').title = ssh ? 'SSH через ' + agent.name : agent.name;
|
||||
if (ssh) this.el.querySelector('.dot').insertAdjacentHTML('afterend', '<span class="badge-ssh">SSH</span>');
|
||||
this.term = new Terminal({
|
||||
cursorBlink: true, fontSize: touch ? 13 : 14,
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', theme: { background: '#0c0f13' }
|
||||
});
|
||||
this.fitter = new FitAddon.FitAddon();
|
||||
this.term.loadAddon(this.fitter);
|
||||
this.term.open(this.el.querySelector('.pane-body'));
|
||||
this.term.attachCustomKeyEventHandler(ev => clipKeys(this, ev));
|
||||
this.term.onData(d => this.send(TermKeys.withMods(d)));
|
||||
this.term.onResize(() => this.queueIn({ t: 'r', cols: this.term.cols, rows: this.term.rows }));
|
||||
this.term.onSelectionChange(() => TermKeys.selectionChanged(this));
|
||||
this.el.addEventListener('pointerdown', () => setActivePane(this), true);
|
||||
this.term.textarea.addEventListener('focus', () => setActivePane(this));
|
||||
this.el.querySelector('.pane-x').onclick = ev => { ev.stopPropagation(); closePane(this); };
|
||||
if (desk) this.el.querySelector('.pane-head').addEventListener('pointerdown', ev => startDrag(ev, 'pane', this));
|
||||
this.open();
|
||||
}
|
||||
setState(state, text) {
|
||||
this.state = state; this.text = text;
|
||||
this.el.querySelector('.dot').className = 'dot ' + state;
|
||||
const t = tabOf(this);
|
||||
if (t) renderTab(t);
|
||||
showStatus();
|
||||
}
|
||||
async open() {
|
||||
try {
|
||||
let opt = { method: 'POST' };
|
||||
if (this.ssh) {
|
||||
opt = { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ssh: this.ssh }) };
|
||||
// The password or key goes once, to the agent; the page keeps no copy.
|
||||
this.ssh = { host: this.ssh.host, port: this.ssh.port, user: this.ssh.user };
|
||||
}
|
||||
const r = await fetch(base + encodeURIComponent(this.agent.id) + '/open', opt);
|
||||
if (!r.ok) {
|
||||
let why = 'HTTP ' + r.status;
|
||||
try { why = (await r.json()).error || why; } catch (e) { /* plain text */ }
|
||||
this.setState('err', 'ошибка открытия: ' + why);
|
||||
this.term.write('\r\n\x1b[31m[не удалось открыть сессию: ' + why + ']\x1b[0m\r\n');
|
||||
return;
|
||||
}
|
||||
this.sid = (await r.json()).session_id;
|
||||
this.setState('wait', 'ожидание агента…');
|
||||
// An agent from before SSH terminals does not answer the request.
|
||||
if (this.ssh) setTimeout(() => {
|
||||
if (!this.closed && !this.ended && this.text === 'ожидание агента…') {
|
||||
this.setState('err', 'агент не начал SSH-подключение');
|
||||
this.term.write('\x1b[33mАгент не ответил. Если он старой версии, обновите его - SSH-терминал появился в новой.\x1b[0m\r\n');
|
||||
}
|
||||
}, 20000);
|
||||
this.fit();
|
||||
// A WebSocket if it connects; the long polls otherwise, and from a
|
||||
// dropped WebSocket on for the rest of this session.
|
||||
if (!(await this.openWS())) this.poll();
|
||||
this.queueIn({ t: 'r', cols: this.term.cols, rows: this.term.rows });
|
||||
} catch (e) {
|
||||
this.setState('err', 'ошибка сети');
|
||||
this.term.write('\r\n\x1b[31m[ошибка сети: ' + (e && e.message ? e.message : e) + ']\x1b[0m\r\n');
|
||||
}
|
||||
}
|
||||
openWS() {
|
||||
return new Promise(resolve => {
|
||||
if (!window.WebSocket) { resolve(false); return; }
|
||||
const ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + base + this.sid + '/ws');
|
||||
let opened = false;
|
||||
const timer = setTimeout(() => { if (!opened) { ws.close(); resolve(false); } }, 5000);
|
||||
ws.onopen = () => { opened = true; clearTimeout(timer); this.ws = ws; resolve(true); };
|
||||
ws.onmessage = ev => {
|
||||
let j;
|
||||
try { j = JSON.parse(ev.data); } catch (e) { return; }
|
||||
this.take(j);
|
||||
};
|
||||
ws.onclose = () => {
|
||||
clearTimeout(timer);
|
||||
if (!opened) { resolve(false); return; }
|
||||
this.ws = null;
|
||||
if (!this.closed && !this.ended) this.poll();
|
||||
};
|
||||
});
|
||||
}
|
||||
// take applies one answer of the output channel, either transport's.
|
||||
take(j) {
|
||||
if (j.closed) { this.finish('[сессия завершена]'); return false; }
|
||||
for (const f of j.frames || []) {
|
||||
if (f.status) this.sshStatus(f.status, f.note || '');
|
||||
if (f.exit) { this.finish('[сессия завершена]'); return false; }
|
||||
if (f.data) {
|
||||
if (!this.connected && !this.ssh) { this.connected = true; this.setState('ok', 'подключено'); }
|
||||
this.term.write(b64ToBytes(f.data));
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// sshStatus shows how the agent's SSH connection goes.
|
||||
sshStatus(st, note) {
|
||||
if (st === 'dial') this.setState('wait', 'SSH: ' + note + '…');
|
||||
else if (st === 'auth') this.setState('wait', 'SSH: ' + note + '…');
|
||||
else if (st === 'ok') { this.connected = true; this.setState('ok', 'SSH: ' + note); }
|
||||
else if (st === 'err') this.setState('err', 'SSH: ' + note);
|
||||
}
|
||||
async poll() {
|
||||
while (!this.closed && this.sid) {
|
||||
let r;
|
||||
try { r = await fetch(base + this.sid + '/out', { cache: 'no-store' }); }
|
||||
catch (e) { await new Promise(res => setTimeout(res, 1000)); continue; }
|
||||
if (this.closed) return;
|
||||
if (!r.ok) { this.finish('[соединение закрыто]'); return; }
|
||||
if (!this.take(await r.json())) return;
|
||||
}
|
||||
}
|
||||
finish(msg) {
|
||||
if (this.ended) return;
|
||||
this.ended = true;
|
||||
if (this.state !== 'err') this.setState('off', 'соединение закрыто');
|
||||
this.term.write('\r\n\x1b[33m' + msg + '\x1b[0m\r\n');
|
||||
}
|
||||
// Input goes out one request at a time, so on a slow link a keystroke can
|
||||
// never overtake the one before it; a request that fails goes again under
|
||||
// the same number, and the server drops one it already has.
|
||||
queueIn(m) {
|
||||
if (this.closed || !this.sid) return;
|
||||
const q = this.inQueue, last = q[q.length - 1];
|
||||
if (last && last.t === 'i' && m.t === 'i') last.text += m.text;
|
||||
else if (last && last.t === 'r' && m.t === 'r') q[q.length - 1] = m;
|
||||
else q.push(m);
|
||||
this.pumpIn();
|
||||
}
|
||||
async pumpIn() {
|
||||
if (this.inBusy || !this.inQueue.length) return;
|
||||
this.inBusy = true;
|
||||
while (this.inQueue.length && !this.closed) {
|
||||
const m = this.inQueue.shift();
|
||||
const body = m.t === 'i'
|
||||
? { t: 'i', d: bytesToB64(new TextEncoder().encode(m.text)), seq: ++this.inSeq }
|
||||
: { t: 'r', cols: m.cols, rows: m.rows, seq: ++this.inSeq };
|
||||
// On the WebSocket a message goes out in order with no answer to wait
|
||||
// for; numbered as well, so a repeat by the long polls is dropped.
|
||||
if (this.ws && this.ws.readyState === 1) {
|
||||
try { this.ws.send(JSON.stringify(body)); continue; } catch (e) { /* by request below */ }
|
||||
}
|
||||
for (let wait = 300; !this.closed; wait = Math.min(wait * 2, 5000)) {
|
||||
let r = null;
|
||||
try {
|
||||
r = await fetch(base + this.sid + '/in', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
} catch (e) { /* no answer: send it again */ }
|
||||
if (r && r.status < 500) break;
|
||||
await new Promise(res => setTimeout(res, wait));
|
||||
}
|
||||
}
|
||||
this.inBusy = false;
|
||||
}
|
||||
send(data) { this.queueIn({ t: 'i', text: data }); }
|
||||
fit() {
|
||||
const b = this.el.querySelector('.pane-body');
|
||||
if (b.clientWidth > 20 && b.clientHeight > 20) { try { this.fitter.fit(); } catch (e) { /* not shown yet */ } }
|
||||
}
|
||||
close() {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
if (this.ws) { try { this.ws.close(); } catch (e) { /* closed already */ } }
|
||||
if (this.sid) fetch(base + this.sid + '/close', { method: 'POST', keepalive: true }).catch(() => {});
|
||||
this.term.dispose();
|
||||
this.el.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// who is what a shell is signed in as, for titles.
|
||||
function who(s) {
|
||||
return s.ssh ? s.ssh.user + '@' + s.ssh.host + (s.ssh.port && s.ssh.port !== 22 ? ':' + s.ssh.port : '') : s.agent.user + '@' + s.agent.host;
|
||||
}
|
||||
|
||||
// ---------- tabs ----------
|
||||
const tabs = [];
|
||||
let active = null, activePane = null;
|
||||
const tabOf = s => tabs.find(t => t.panes.includes(s));
|
||||
|
||||
function makeTab(panes, index) {
|
||||
const t = { title: null, panes: panes, layout: panes.length === 1 ? '1' : 'h', c: 0.5, r: 0.5, gutters: [] };
|
||||
t.view = document.createElement('div');
|
||||
t.view.className = 'tabview';
|
||||
area.insertBefore(t.view, hint);
|
||||
panes.forEach(s => t.view.appendChild(s.el));
|
||||
t.el = document.createElement('div');
|
||||
t.el.className = 'tab enter';
|
||||
t.el.innerHTML = '<span class="dot wait"></span><span class="tab-title"></span>' +
|
||||
'<button type="button" class="tab-x" title="Закрыть вкладку" aria-label="Закрыть вкладку">' + X + '</button>';
|
||||
t.el.addEventListener('click', ev => { if (!ev.target.closest('.tab-x, input') && !dragJustEnded) activate(t); });
|
||||
t.el.querySelector('.tab-x').onclick = ev => { ev.stopPropagation(); closeTab(t); };
|
||||
t.el.querySelector('.tab-title').addEventListener('dblclick', ev => { ev.stopPropagation(); rename(t); });
|
||||
if (desk) t.el.addEventListener('pointerdown', ev => { if (!ev.target.closest('.tab-x, input')) startDrag(ev, 'tab', t); });
|
||||
const at = index == null ? tabs.length : index;
|
||||
tablist.insertBefore(t.el, tabs[at] ? tabs[at].el : mark);
|
||||
tabs.splice(at, 0, t);
|
||||
renderTab(t);
|
||||
fitTabs();
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => t.el.classList.remove('enter')));
|
||||
return t;
|
||||
}
|
||||
function tabTitle(t) {
|
||||
if (t.title) return t.title;
|
||||
const s = t.panes[0], name = s.ssh ? s.ssh.host : s.agent.name;
|
||||
return t.panes.length > 1 ? name + ' +' + (t.panes.length - 1) : name;
|
||||
}
|
||||
// Tab widths: as wide as the title wants, from a comfortable minimum up to
|
||||
// a maximum; when they do not fit, all give way evenly down to a floor,
|
||||
// titles cut with "…", and below the floor the strip scrolls instead.
|
||||
const ruler = document.createElement('canvas').getContext('2d');
|
||||
const W = touch ? { min: 76, base: 124, max: 180, font: 12 } : { min: 96, base: 180, max: 300, font: 13 };
|
||||
const CHROME = touch ? 52 : 62; // padding, dot, gaps, close button, borders
|
||||
function fitTabs() {
|
||||
ruler.font = W.font + 'px ' + getComputedStyle(document.body).fontFamily;
|
||||
const live = tabs;
|
||||
const want = live.map(t => Math.min(W.max, Math.max(W.base, Math.ceil(ruler.measureText(tabTitle(t)).width) + CHROME + (t.panes.some(p => p.ssh) ? 46 : 0))));
|
||||
const strip = $('tabs');
|
||||
const room = strip.clientWidth - parseFloat(getComputedStyle(strip).paddingLeft) * 2 - addBtn.offsetWidth - 4 * (live.length + 1);
|
||||
let cap = W.max;
|
||||
if (want.reduce((a, b) => a + b, 0) > room) {
|
||||
// The widest give way first, to one width for all at the most.
|
||||
let lo = W.min, hi = W.max;
|
||||
for (let k = 0; k < 20; k++) {
|
||||
const mid = (lo + hi) / 2;
|
||||
if (want.reduce((a, b) => a + Math.min(b, mid), 0) > room) hi = mid; else lo = mid;
|
||||
}
|
||||
cap = Math.max(W.min, Math.floor(lo));
|
||||
}
|
||||
live.forEach((t, i) => { t.el.style.width = Math.min(want[i], cap) + 'px'; });
|
||||
}
|
||||
window.addEventListener('resize', () => fitTabs());
|
||||
|
||||
function renderTab(t) {
|
||||
t.el.querySelector('.tab-title').textContent = tabTitle(t);
|
||||
const ssh = t.panes.some(s => s.ssh);
|
||||
if (ssh !== !!t.el.querySelector('.badge-ssh')) {
|
||||
if (ssh) t.el.querySelector('.dot').insertAdjacentHTML('afterend', '<span class="badge-ssh">SSH</span>');
|
||||
else t.el.querySelector('.badge-ssh').remove();
|
||||
}
|
||||
requestAnimationFrame(fitTabs);
|
||||
t.el.title = t.panes.map(who).join(', ');
|
||||
// The tab's dot: red if any shell failed, amber while one connects.
|
||||
const st = t.panes.map(s => s.state);
|
||||
t.el.querySelector('.dot').className = 'dot ' + (st.includes('err') ? 'err' : st.includes('wait') ? 'wait' : st.includes('ok') ? 'ok' : 'off');
|
||||
t.el.classList.toggle('on', t === active);
|
||||
t.view.classList.toggle('split', t.panes.length > 1);
|
||||
syncFloat();
|
||||
}
|
||||
function activate(t) {
|
||||
if (!t) return;
|
||||
active = t;
|
||||
tabs.forEach(x => { x.view.classList.toggle('on', x === t); renderTab(x); });
|
||||
if (!t.panes.includes(activePane)) setActivePane(t.panes[0]);
|
||||
layout(t, false);
|
||||
t.el.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'smooth' });
|
||||
if (desk && activePane) activePane.term.focus();
|
||||
updateMode();
|
||||
}
|
||||
function setActivePane(s) {
|
||||
activePane = s;
|
||||
document.querySelectorAll('.pane.active').forEach(p => p.classList.toggle('active', p === s.el));
|
||||
s.el.classList.add('active');
|
||||
showStatus();
|
||||
}
|
||||
function showStatus() {
|
||||
if (!activePane) { statusEl.textContent = ''; return; }
|
||||
statusEl.textContent = activePane.text;
|
||||
statusEl.title = activePane.text;
|
||||
statusEl.className = activePane.state === 'ok' ? 'st-ok' : activePane.state === 'err' ? 'st-err' : 'muted';
|
||||
}
|
||||
// The corner "+" moves down out of the way of a split tab's title bars.
|
||||
function syncFloat() {
|
||||
document.body.classList.toggle('split1', tabs.length === 1 && tabs[0].panes.length > 1);
|
||||
}
|
||||
// One tab: a plain terminal, the strip folded into the corner "+".
|
||||
function updateMode() {
|
||||
syncFloat();
|
||||
const multi = tabs.length > 1;
|
||||
if (document.body.classList.contains('multi') === multi) return;
|
||||
document.body.classList.toggle('multi', multi);
|
||||
setTimeout(() => active && layout(active, false), 280);
|
||||
}
|
||||
function openTerminal(agent, ssh) {
|
||||
$('empty').hidden = true;
|
||||
const t = makeTab([new Session(agent, ssh)]);
|
||||
activate(t);
|
||||
}
|
||||
function closeTab(t) {
|
||||
const i = tabs.indexOf(t);
|
||||
if (i < 0) return;
|
||||
if (t.panes.length > 1 && !confirm('Закрыть вкладку с ' + t.panes.length + ' терминалами?')) return;
|
||||
tabs.splice(i, 1);
|
||||
t.panes.forEach(s => s.close());
|
||||
t.el.classList.add('leave');
|
||||
t.view.classList.remove('on');
|
||||
setTimeout(() => { t.el.remove(); t.view.remove(); }, 220);
|
||||
if (active === t) {
|
||||
active = null;
|
||||
activate(tabs[Math.min(i, tabs.length - 1)]);
|
||||
}
|
||||
if (!tabs.length) { activePane = null; showStatus(); $('empty').hidden = false; }
|
||||
updateMode();
|
||||
}
|
||||
function rename(t) {
|
||||
const span = t.el.querySelector('.tab-title');
|
||||
const inp = document.createElement('input');
|
||||
inp.className = 'tab-edit';
|
||||
inp.value = tabTitle(t);
|
||||
inp.size = Math.max(6, inp.value.length);
|
||||
span.replaceWith(inp);
|
||||
inp.focus(); inp.select();
|
||||
let done = false;
|
||||
const end = save => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
if (save) t.title = inp.value.trim() || null;
|
||||
inp.replaceWith(span);
|
||||
renderTab(t);
|
||||
};
|
||||
inp.onkeydown = ev => { if (ev.key === 'Enter') end(true); else if (ev.key === 'Escape') end(false); ev.stopPropagation(); };
|
||||
inp.onblur = () => end(true);
|
||||
inp.onpointerdown = ev => ev.stopPropagation();
|
||||
}
|
||||
|
||||
// ---------- splits ----------
|
||||
// Where each terminal of a tab goes, and its gutters, for a layout:
|
||||
// 1, h (side by side), v (one above the other), top2 (two over one),
|
||||
// bottom2 (one over two), grid (two over two). c and r are where the
|
||||
// column and the row divide.
|
||||
function rects(layout, c, r, W, H) {
|
||||
const g = GAP, cw = Math.round((W - g) * c), rh = Math.round((H - g) * r);
|
||||
const top = (y, h) => [{ x: 0, y, w: cw, h }, { x: cw + g, y, w: W - cw - g, h }];
|
||||
const colG = (y, h) => ({ k: 'c', x: cw, y, w: g, h });
|
||||
const rowG = { k: 'r', x: 0, y: rh, w: W, h: g };
|
||||
const bottomY = rh + g, bottomH = H - rh - g;
|
||||
switch (layout) {
|
||||
case 'h': return { panes: top(0, H), gutters: [colG(0, H)] };
|
||||
case 'v': return { panes: [{ x: 0, y: 0, w: W, h: rh }, { x: 0, y: bottomY, w: W, h: bottomH }], gutters: [rowG] };
|
||||
case 'top2': return { panes: top(0, rh).concat([{ x: 0, y: bottomY, w: W, h: bottomH }]), gutters: [colG(0, rh), rowG] };
|
||||
case 'bottom2': return { panes: [{ x: 0, y: 0, w: W, h: rh }].concat(top(bottomY, bottomH)), gutters: [colG(bottomY, bottomH), rowG] };
|
||||
case 'grid': return { panes: top(0, rh).concat(top(bottomY, bottomH)), gutters: [colG(0, H), rowG] };
|
||||
default: return { panes: [{ x: 0, y: 0, w: W, h: H }], gutters: [] };
|
||||
}
|
||||
}
|
||||
function layout(t, animate) {
|
||||
const W = t.view.clientWidth, H = t.view.clientHeight;
|
||||
if (!W || !H) return;
|
||||
const L = rects(t.layout, t.c, t.r, W, H);
|
||||
t.view.classList.toggle('instant', !animate);
|
||||
t.panes.forEach((s, i) => {
|
||||
const p = L.panes[i];
|
||||
s.el.style.left = p.x + 'px'; s.el.style.top = p.y + 'px';
|
||||
s.el.style.width = p.w + 'px'; s.el.style.height = p.h + 'px';
|
||||
});
|
||||
while (t.gutters.length < L.gutters.length) {
|
||||
const g = document.createElement('div');
|
||||
g.className = 'gutter';
|
||||
g.addEventListener('pointerdown', ev => startResize(ev, t, g));
|
||||
t.view.appendChild(g);
|
||||
t.gutters.push(g);
|
||||
}
|
||||
t.gutters.forEach((g, i) => {
|
||||
const d = L.gutters[i];
|
||||
g.hidden = !d;
|
||||
if (!d) return;
|
||||
g.className = 'gutter ' + d.k;
|
||||
g.style.left = d.x + 'px'; g.style.top = d.y + 'px'; g.style.width = d.w + 'px'; g.style.height = d.h + 'px';
|
||||
});
|
||||
clearTimeout(t.fitTimer);
|
||||
t.fitTimer = setTimeout(() => t.panes.forEach(s => s.fit()), animate ? 270 : 0);
|
||||
}
|
||||
function startResize(ev, t, g) {
|
||||
ev.preventDefault();
|
||||
g.setPointerCapture(ev.pointerId);
|
||||
const box = t.view.getBoundingClientRect(), k = g.classList.contains('c') ? 'c' : 'r';
|
||||
g.classList.add('drag');
|
||||
let raf = 0;
|
||||
const move = e => {
|
||||
const v = k === 'c' ? (e.clientX - box.left) / box.width : (e.clientY - box.top) / box.height;
|
||||
t[k] = Math.min(0.85, Math.max(0.15, v));
|
||||
if (!raf) raf = requestAnimationFrame(() => { raf = 0; layout(t, false); });
|
||||
};
|
||||
const up = () => {
|
||||
g.classList.remove('drag');
|
||||
g.removeEventListener('pointermove', move);
|
||||
g.removeEventListener('pointerup', up);
|
||||
layout(t, false);
|
||||
};
|
||||
g.addEventListener('pointermove', move);
|
||||
g.addEventListener('pointerup', up);
|
||||
}
|
||||
// A layout with one terminal fewer, the others keeping their places.
|
||||
function withoutPane(layout, i) {
|
||||
if (layout === 'h' || layout === 'v') return '1';
|
||||
if (layout === 'top2') return i === 2 ? 'h' : 'v';
|
||||
if (layout === 'bottom2') return i === 0 ? 'h' : 'v';
|
||||
if (layout === 'grid') return i < 2 ? 'bottom2' : 'top2';
|
||||
return '1';
|
||||
}
|
||||
function takePane(t, s) {
|
||||
const i = t.panes.indexOf(s);
|
||||
t.layout = withoutPane(t.layout, i);
|
||||
t.panes.splice(i, 1);
|
||||
}
|
||||
function closePane(s) {
|
||||
const t = tabOf(s);
|
||||
if (!t) return;
|
||||
if (t.panes.length === 1) { closeTab(t); return; }
|
||||
takePane(t, s);
|
||||
s.close();
|
||||
if (activePane === s) setActivePane(t.panes[0]);
|
||||
renderTab(t);
|
||||
layout(t, true);
|
||||
}
|
||||
// Where a terminal dropped on tab t at (x, y) would go: the new layout,
|
||||
// the order of its terminals ('P' the new one), and its place.
|
||||
const SIDE = { left: 'слева', right: 'справа', top: 'сверху', bottom: 'снизу' };
|
||||
function mergePlan(t, x, y) {
|
||||
const n = t.panes.length, W = t.view.clientWidth, H = t.view.clientHeight;
|
||||
const fx = x / W, fy = y / H;
|
||||
let lay, order, label;
|
||||
if (n === 1) {
|
||||
const d = { left: fx, right: 1 - fx, top: fy, bottom: 1 - fy };
|
||||
const k = Object.keys(d).reduce((a, b) => d[a] <= d[b] ? a : b);
|
||||
lay = k === 'left' || k === 'right' ? 'h' : 'v';
|
||||
order = k === 'left' || k === 'top' ? ['P', 0] : [0, 'P'];
|
||||
label = SIDE[k];
|
||||
} else if (n === 2 && t.layout === 'h') {
|
||||
if (fy < 0.5) { lay = 'bottom2'; order = ['P', 0, 1]; label = 'сверху'; }
|
||||
else { lay = 'top2'; order = [0, 1, 'P']; label = 'снизу'; }
|
||||
} else if (n === 2) {
|
||||
if (fy < 0.5) { lay = 'top2'; order = fx < 0.5 ? ['P', 0, 1] : [0, 'P', 1]; }
|
||||
else { lay = 'bottom2'; order = fx < 0.5 ? [0, 'P', 1] : [0, 1, 'P']; }
|
||||
label = (fy < 0.5 ? 'сверху ' : 'снизу ') + (fx < 0.5 ? 'слева' : 'справа');
|
||||
} else if (n === 3) {
|
||||
lay = 'grid';
|
||||
if (t.layout === 'top2') { order = fx < 0.5 ? [0, 1, 'P', 2] : [0, 1, 2, 'P']; label = 'снизу ' + (fx < 0.5 ? 'слева' : 'справа'); }
|
||||
else { order = fx < 0.5 ? ['P', 0, 1, 2] : [0, 'P', 1, 2]; label = 'сверху ' + (fx < 0.5 ? 'слева' : 'справа'); }
|
||||
} else return null;
|
||||
return { layout: lay, order, label, rect: rects(lay, t.c, t.r, W, H).panes[order.indexOf('P')] };
|
||||
}
|
||||
|
||||
// ---------- drag and drop ----------
|
||||
// A tab: along the strip to reorder it, or onto the current tab to join
|
||||
// it. A terminal from a split, by its title bar: to the strip to make it a
|
||||
// tab of its own, or onto another terminal of its tab to swap places.
|
||||
let drag = null, dragJustEnded = false;
|
||||
function startDrag(ev, kind, obj) {
|
||||
if (ev.button !== 0) return;
|
||||
drag = { kind, obj, x0: ev.clientX, y0: ev.clientY, on: false, id: ev.pointerId };
|
||||
}
|
||||
function beginDrag() {
|
||||
drag.on = true;
|
||||
const src = drag.kind === 'tab' ? drag.obj.el : drag.obj.el.querySelector('.pane-head');
|
||||
const g = document.createElement('div');
|
||||
g.className = 'drag-ghost ' + drag.kind;
|
||||
g.textContent = drag.kind === 'tab' ? tabTitle(drag.obj) : drag.obj.agent.user + '@' + drag.obj.agent.host;
|
||||
document.body.appendChild(g);
|
||||
drag.ghost = g;
|
||||
drag.src = src;
|
||||
src.classList.add('dragging');
|
||||
document.body.classList.add('dnd');
|
||||
// Pulling a terminal out: the strip shows, one tab or not, to drop it on.
|
||||
if (drag.kind === 'pane') document.body.classList.add('multi-hint');
|
||||
}
|
||||
function stripIndex(x) {
|
||||
const others = tabs.filter(t => !(drag.kind === 'tab' && t === drag.obj));
|
||||
let i = others.findIndex(t => { const r = t.el.getBoundingClientRect(); return x < r.left + r.width / 2; });
|
||||
if (i < 0) i = others.length;
|
||||
const ref = others[i] ? others[i].el.getBoundingClientRect() : (others.length ? others[others.length - 1].el.getBoundingClientRect() : null);
|
||||
const lr = tablist.getBoundingClientRect();
|
||||
const mx = others[i] ? ref.left - 3 : ref ? ref.right + 1 : lr.left;
|
||||
return { i, others, mx: mx - lr.left + tablist.scrollLeft };
|
||||
}
|
||||
function showHint(r, label, bad) {
|
||||
hint.hidden = false;
|
||||
hint.classList.toggle('bad', !!bad);
|
||||
const v = active.view;
|
||||
hint.style.left = (v.offsetLeft + r.x) + 'px'; hint.style.top = (v.offsetTop + r.y) + 'px';
|
||||
hint.style.width = r.w + 'px'; hint.style.height = r.h + 'px';
|
||||
hint.textContent = label;
|
||||
}
|
||||
function onDragMove(ev) {
|
||||
if (!drag || ev.pointerId !== drag.id) return;
|
||||
if (!drag.on) {
|
||||
if (Math.hypot(ev.clientX - drag.x0, ev.clientY - drag.y0) < 6) return;
|
||||
beginDrag();
|
||||
}
|
||||
drag.ghost.style.transform = 'translate(' + (ev.clientX + 10) + 'px,' + (ev.clientY + 8) + 'px)';
|
||||
drag.target = null;
|
||||
hint.hidden = true; mark.hidden = true;
|
||||
const strip = $('tabs').getBoundingClientRect();
|
||||
const stripShown = document.body.classList.contains('multi') || document.body.classList.contains('multi-hint');
|
||||
const inStrip = stripShown && ev.clientY >= strip.top - 6 && ev.clientY <= strip.bottom + 10;
|
||||
const inArea = active && (() => { const r = active.view.getBoundingClientRect(); return ev.clientX >= r.left && ev.clientX <= r.right && ev.clientY >= r.top && ev.clientY <= r.bottom; })();
|
||||
if (inStrip) {
|
||||
const s = stripIndex(ev.clientX);
|
||||
mark.hidden = false; mark.style.left = s.mx + 'px';
|
||||
drag.target = { kind: 'strip', index: s.i, others: s.others };
|
||||
drag.ghost.dataset.hint = drag.kind === 'tab' ? 'переставить' : 'отдельной вкладкой';
|
||||
} else if (inArea && drag.kind === 'tab' && drag.obj !== active) {
|
||||
const r = active.view.getBoundingClientRect();
|
||||
if (drag.obj.panes.length > 1) {
|
||||
showHint({ x: 0, y: 0, w: r.width, h: r.height }, 'вкладку с несколькими терминалами не объединить', true);
|
||||
} else if (active.panes.length >= 4) {
|
||||
showHint({ x: 0, y: 0, w: r.width, h: r.height }, 'в одной вкладке не больше 4 терминалов', true);
|
||||
} else {
|
||||
const plan = mergePlan(active, ev.clientX - r.left, ev.clientY - r.top);
|
||||
showHint(plan.rect, plan.label);
|
||||
drag.target = { kind: 'merge', plan };
|
||||
}
|
||||
drag.ghost.dataset.hint = '';
|
||||
} else if (inArea && drag.kind === 'pane' && tabOf(drag.obj) === active) {
|
||||
const r = active.view.getBoundingClientRect();
|
||||
const over = active.panes.find(s => { const p = s.el.getBoundingClientRect(); return s !== drag.obj && ev.clientX >= p.left && ev.clientX <= p.right && ev.clientY >= p.top && ev.clientY <= p.bottom; });
|
||||
if (over) {
|
||||
const p = over.el.getBoundingClientRect();
|
||||
showHint({ x: p.left - r.left, y: p.top - r.top, w: p.width, h: p.height }, 'поменять местами');
|
||||
drag.target = { kind: 'swap', with: over };
|
||||
}
|
||||
drag.ghost.dataset.hint = over ? '' : 'к вкладкам - отдельной вкладкой';
|
||||
} else {
|
||||
drag.ghost.dataset.hint = '';
|
||||
}
|
||||
}
|
||||
function onDragEnd(ev) {
|
||||
if (!drag || ev.pointerId !== drag.id) return;
|
||||
const d = drag;
|
||||
drag = null;
|
||||
if (!d.on) return;
|
||||
d.ghost.remove();
|
||||
d.src.classList.remove('dragging');
|
||||
document.body.classList.remove('dnd', 'multi-hint');
|
||||
hint.hidden = true; mark.hidden = true;
|
||||
dragJustEnded = true;
|
||||
setTimeout(() => { dragJustEnded = false; }, 0);
|
||||
const tg = d.target;
|
||||
if (!tg) return;
|
||||
if (d.kind === 'tab' && tg.kind === 'strip') {
|
||||
const t = d.obj, ref = tg.others[tg.index];
|
||||
tabs.splice(tabs.indexOf(t), 1);
|
||||
tabs.splice(ref ? tabs.indexOf(ref) : tabs.length, 0, t);
|
||||
tablist.insertBefore(t.el, ref ? ref.el : mark);
|
||||
} else if (d.kind === 'tab' && tg.kind === 'merge') {
|
||||
const src = d.obj, s = src.panes[0], t = active, plan = tg.plan;
|
||||
// The joining terminal starts where the hint showed it.
|
||||
s.el.classList.add('instant');
|
||||
s.el.style.left = plan.rect.x + 'px'; s.el.style.top = plan.rect.y + 'px';
|
||||
s.el.style.width = plan.rect.w + 'px'; s.el.style.height = plan.rect.h + 'px';
|
||||
t.view.appendChild(s.el);
|
||||
requestAnimationFrame(() => s.el.classList.remove('instant'));
|
||||
t.panes = plan.order.map(o => o === 'P' ? s : t.panes[o]);
|
||||
t.layout = plan.layout;
|
||||
tabs.splice(tabs.indexOf(src), 1);
|
||||
src.el.classList.add('leave');
|
||||
setTimeout(() => { src.el.remove(); src.view.remove(); }, 220);
|
||||
setActivePane(s);
|
||||
renderTab(t);
|
||||
layout(t, true);
|
||||
updateMode();
|
||||
s.term.focus();
|
||||
} else if (d.kind === 'pane' && tg.kind === 'strip') {
|
||||
const s = d.obj, from = tabOf(s), ref = tg.others[tg.index];
|
||||
takePane(from, s);
|
||||
renderTab(from);
|
||||
const t = makeTab([s], ref ? tabs.indexOf(ref) : tabs.length);
|
||||
layout(from, true);
|
||||
activate(t);
|
||||
} else if (d.kind === 'pane' && tg.kind === 'swap') {
|
||||
const t = active, a = t.panes.indexOf(d.obj), b = t.panes.indexOf(tg.with);
|
||||
[t.panes[a], t.panes[b]] = [t.panes[b], t.panes[a]];
|
||||
layout(t, true);
|
||||
}
|
||||
}
|
||||
window.addEventListener('pointermove', onDragMove);
|
||||
window.addEventListener('pointerup', onDragEnd);
|
||||
window.addEventListener('pointercancel', onDragEnd);
|
||||
|
||||
// ---------- the "+" menu ----------
|
||||
let agentsCache = null;
|
||||
function openMenu(anchor) {
|
||||
const here = (activePane && activePane.agent) || PAGE.agent;
|
||||
addMenu.innerHTML = '';
|
||||
const item = (html, fn, cls) => {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button'; b.className = 'item ' + (cls || ''); b.innerHTML = html;
|
||||
b.onclick = fn;
|
||||
addMenu.appendChild(b);
|
||||
return b;
|
||||
};
|
||||
const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||
item('<span class="ico">+</span><span><b>Новый терминал здесь</b><small>' + esc(here.name) + ' · ' + esc(here.user + '@' + here.host) + '</small></span>',
|
||||
() => { closeMenu(); openTerminal(here); });
|
||||
item('<span class="ico">' + KEYI + '</span><span><b>SSH-подключение</b><small>агент откроет SSH к другому хосту</small></span>',
|
||||
() => { closeMenu(); openSSH(here); });
|
||||
const other = item('<span class="ico">⇄</span><span><b>На другой машине</b><small>выбрать из списка</small></span><span class="chev">›</span>', () => {
|
||||
other.remove();
|
||||
const list = document.createElement('div');
|
||||
list.className = 'mlist';
|
||||
list.innerHTML = '<div class="muted" style="padding:8px 10px;font-size:13px">загрузка…</div>';
|
||||
addMenu.appendChild(list);
|
||||
(agentsCache ? Promise.resolve(agentsCache) : fetch('/api/panel/agents/presence', { cache: 'no-store' }).then(r => r.json()))
|
||||
.then(all => {
|
||||
agentsCache = all;
|
||||
list.innerHTML = '';
|
||||
all.forEach(a => {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button'; b.className = 'item machine'; b.disabled = !a.online;
|
||||
b.innerHTML = '<span class="dot ' + (a.online ? 'ok' : 'off') + '"></span><span><b>' + esc(a.name) + '</b><small>' +
|
||||
esc(a.user + '@' + a.hostname) + (a.online ? '' : ' · ' + esc(a.seen)) + '</small></span>';
|
||||
b.onclick = () => { closeMenu(); openTerminal({ id: a.id, name: a.name, host: a.hostname, user: a.user }); };
|
||||
list.appendChild(b);
|
||||
});
|
||||
if (!all.length) list.innerHTML = '<div class="muted" style="padding:8px 10px">машин нет</div>';
|
||||
placeMenu(anchor);
|
||||
})
|
||||
.catch(() => { list.innerHTML = '<div class="muted" style="padding:8px 10px">не загрузилось</div>'; });
|
||||
});
|
||||
addMenu.hidden = false;
|
||||
placeMenu(anchor);
|
||||
setTimeout(() => document.addEventListener('pointerdown', outside, true), 0);
|
||||
}
|
||||
function placeMenu(anchor) {
|
||||
const r = anchor.getBoundingClientRect(), m = addMenu.getBoundingClientRect();
|
||||
addMenu.style.left = Math.max(8, Math.min(innerWidth - m.width - 8, r.right - m.width)) + 'px';
|
||||
addMenu.style.top = Math.min(innerHeight - m.height - 8, r.bottom + 6) + 'px';
|
||||
}
|
||||
function outside(ev) { if (!addMenu.contains(ev.target)) closeMenu(); }
|
||||
function closeMenu() {
|
||||
addMenu.hidden = true;
|
||||
agentsCache = null;
|
||||
document.removeEventListener('pointerdown', outside, true);
|
||||
}
|
||||
addBtn.onclick = () => openMenu(addBtn);
|
||||
floatAdd.onclick = () => openMenu(floatAdd);
|
||||
$('emptyadd').onclick = ev => openMenu(ev.currentTarget);
|
||||
document.addEventListener('keydown', ev => { if (ev.key === 'Escape' && !addMenu.hidden) closeMenu(); });
|
||||
|
||||
// ---------- the SSH dialog ----------
|
||||
const KEYI = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="7.5" cy="15.5" r="4.5"/><path d="m21 2-9.6 9.6M15.5 7.5l3 3L22 7l-3-3"/></svg>';
|
||||
let sshDlg = null;
|
||||
function openSSH(here) {
|
||||
if (!sshDlg) sshDlg = makeSSHDialog();
|
||||
sshDlg.show(here);
|
||||
}
|
||||
function makeSSHDialog() {
|
||||
const d = document.createElement('dialog');
|
||||
d.className = 'sshdlg';
|
||||
d.innerHTML =
|
||||
'<form method="dialog" autocomplete="off">' +
|
||||
'<div class="dlg-h"><span class="ico">' + KEYI + '</span><strong style="flex:1">SSH-подключение</strong>' +
|
||||
'<button type="button" class="btn ghost small icon close x" data-close aria-label="Закрыть">' + X + '</button></div>' +
|
||||
'<div class="dlg-b">' +
|
||||
'<label class="f"><span>Подключаться с машины</span><select name="agent"></select></label>' +
|
||||
'<div class="frow"><label class="f grow"><span>Хост</span><input type="text" name="host" placeholder="example.com или 10.0.0.5" required autocapitalize="off" spellcheck="false"></label>' +
|
||||
'<label class="f port"><span>Порт</span><input type="number" name="port" min="1" max="65535" value="22" required></label></div>' +
|
||||
'<label class="f"><span>Пользователь</span><input type="text" name="user" placeholder="root" required autocapitalize="off" spellcheck="false"></label>' +
|
||||
'<div class="f"><span>Вход</span><div class="seg" role="tablist">' +
|
||||
'<button type="button" data-auth="password" class="on">Пароль</button><button type="button" data-auth="key">Ключ</button><button type="button" data-auth="agent">Ключи агента</button></div></div>' +
|
||||
'<div class="auth" data-for="password"><input type="password" name="password" placeholder="пароль" autocomplete="off"></div>' +
|
||||
'<div class="auth" data-for="key" hidden>' +
|
||||
'<textarea name="key" rows="5" spellcheck="false" placeholder="-----BEGIN OPENSSH PRIVATE KEY----- …вставьте ключ или загрузите файл"></textarea>' +
|
||||
'<div class="frow"><button type="button" class="btn ghost small" data-load>Загрузить файл…</button><span class="muted keyname"></span>' +
|
||||
'<input type="file" hidden></div>' +
|
||||
'<input type="password" name="passphrase" placeholder="фраза ключа, если есть" autocomplete="off"></div>' +
|
||||
'<p class="auth muted" data-for="agent" hidden>Агент войдёт своими ключами из ~/.ssh (id_ed25519, id_ecdsa, id_rsa) без фразы.</p>' +
|
||||
'<p class="sshmsg"></p>' +
|
||||
'<div class="row end"><button type="button" class="btn ghost" data-close>Отмена</button><button type="submit">Подключиться</button></div>' +
|
||||
'</div></form>';
|
||||
document.body.appendChild(d);
|
||||
const f = d.querySelector('form'), msg = d.querySelector('.sshmsg');
|
||||
let auth = 'password';
|
||||
const setAuth = a => {
|
||||
auth = a;
|
||||
d.querySelectorAll('.seg button').forEach(b => b.classList.toggle('on', b.dataset.auth === a));
|
||||
d.querySelectorAll('.auth').forEach(el => { el.hidden = el.dataset.for !== a; });
|
||||
};
|
||||
d.querySelectorAll('.seg button').forEach(b => { b.onclick = () => setAuth(b.dataset.auth); });
|
||||
d.querySelectorAll('[data-close]').forEach(b => { b.onclick = () => close(); });
|
||||
const file = d.querySelector('input[type=file]');
|
||||
d.querySelector('[data-load]').onclick = () => file.click();
|
||||
file.onchange = () => {
|
||||
const fl = file.files[0];
|
||||
if (!fl) return;
|
||||
if (fl.size > 32768) { msg.textContent = 'файл великоват для ключа'; return; }
|
||||
fl.text().then(t => { f.key.value = t.trim() + '\n'; d.querySelector('.keyname').textContent = fl.name; msg.textContent = ''; });
|
||||
file.value = '';
|
||||
};
|
||||
// then runs once the dialog is shut and focus is back where it was, so
|
||||
// what it focuses stays focused.
|
||||
function close(then) {
|
||||
d.close();
|
||||
if (then) then();
|
||||
}
|
||||
d.addEventListener('cancel', ev => { ev.preventDefault(); close(); });
|
||||
d.addEventListener('click', ev => { if (ev.target === d) close(); });
|
||||
let agents = [];
|
||||
f.onsubmit = ev => {
|
||||
ev.preventDefault();
|
||||
const a = agents.find(x => x.id === f.agent.value);
|
||||
if (!a) { msg.textContent = 'выберите машину'; return; }
|
||||
const t = { host: f.host.value.trim(), port: Number(f.port.value) || 22, user: f.user.value.trim() };
|
||||
if (auth === 'password') {
|
||||
if (!f.password.value) { msg.textContent = 'введите пароль'; f.password.focus(); return; }
|
||||
t.password = f.password.value;
|
||||
} else if (auth === 'key') {
|
||||
if (!/PRIVATE KEY/.test(f.key.value)) { msg.textContent = 'вставьте закрытый ключ или загрузите файл'; return; }
|
||||
t.key = f.key.value;
|
||||
if (f.passphrase.value) t.passphrase = f.passphrase.value;
|
||||
}
|
||||
try { localStorage.setItem('lpra.ssh.last', JSON.stringify({ agent: a.id, host: t.host, port: t.port, user: t.user, auth: auth })); } catch (e) { /* not kept */ }
|
||||
f.password.value = ''; f.passphrase.value = ''; f.key.value = '';
|
||||
d.querySelector('.keyname').textContent = '';
|
||||
close(() => openTerminal(a, t));
|
||||
};
|
||||
return {
|
||||
show(here) {
|
||||
msg.textContent = '';
|
||||
let last = {};
|
||||
try { last = JSON.parse(localStorage.getItem('lpra.ssh.last') || '{}'); } catch (e) { /* none */ }
|
||||
agents = [here];
|
||||
const fill = () => {
|
||||
f.agent.innerHTML = '';
|
||||
agents.forEach(a => {
|
||||
const o = document.createElement('option');
|
||||
o.value = a.id; o.textContent = a.name + ' · ' + a.user + '@' + a.host + (a.online === false ? ' (не в сети)' : '');
|
||||
o.disabled = a.online === false;
|
||||
f.agent.appendChild(o);
|
||||
});
|
||||
f.agent.value = agents.some(a => a.id === last.agent && a.online !== false) ? last.agent : here.id;
|
||||
};
|
||||
fill();
|
||||
fetch('/api/panel/agents/presence', { cache: 'no-store' }).then(r => r.json()).then(all => {
|
||||
agents = all.map(a => ({ id: a.id, name: a.name, host: a.hostname, user: a.user, online: a.online }));
|
||||
if (!agents.some(a => a.id === here.id)) agents.unshift(here);
|
||||
fill();
|
||||
}).catch(() => {});
|
||||
if (last.host) { f.host.value = last.host; f.port.value = last.port || 22; f.user.value = last.user || ''; }
|
||||
setAuth(last.auth === 'key' || last.auth === 'agent' ? last.auth : 'password');
|
||||
d.showModal();
|
||||
// On a phone the keyboard waits for a tap.
|
||||
if (desk) (last.host ? (last.auth === 'password' || !last.auth ? f.password : f.host) : f.host).focus();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- page ----------
|
||||
function refit() {
|
||||
if (touch && window.visualViewport) {
|
||||
// Follow the visible area, so the extra keys sit right above the
|
||||
// on-screen keyboard.
|
||||
const vv = window.visualViewport;
|
||||
document.body.style.top = vv.offsetTop + 'px';
|
||||
document.body.style.height = vv.height + 'px';
|
||||
}
|
||||
if (active) layout(active, false);
|
||||
}
|
||||
window.addEventListener('resize', refit);
|
||||
if (touch && window.visualViewport) {
|
||||
window.visualViewport.addEventListener('resize', refit);
|
||||
window.visualViewport.addEventListener('scroll', refit);
|
||||
}
|
||||
window.addEventListener('pagehide', () => {
|
||||
tabs.forEach(t => t.panes.forEach(s => { if (s.sid) navigator.sendBeacon(base + s.sid + '/close'); }));
|
||||
});
|
||||
|
||||
// For the touch helpers in the page: the terminal being typed in.
|
||||
window.TermPane = { active: () => activePane };
|
||||
openTerminal(PAGE.agent);
|
||||
refit();
|
||||
})();
|
||||
@@ -0,0 +1,105 @@
|
||||
// The users table in Settings: the role, the machines a user may use (a
|
||||
// dialog with a list to tick), the password sign-in and whether the user
|
||||
// may switch it themselves. Each change is saved at once.
|
||||
(function () {
|
||||
'use strict';
|
||||
var table = document.querySelector('table.users');
|
||||
if (!table) return;
|
||||
var $ = function (id) { return document.getElementById(id); };
|
||||
|
||||
function save(id, body) {
|
||||
return fetch('/api/panel/users/' + id, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)})
|
||||
.then(function (r) { return r.json(); })
|
||||
.catch(function () { return {ok: false, error: 'ошибка сети'}; });
|
||||
}
|
||||
function note(tr, text, bad) {
|
||||
var n = tr.querySelector('.u-note');
|
||||
if (!n) {
|
||||
n = document.createElement('div');
|
||||
n.className = 'u-note';
|
||||
tr.cells[0].appendChild(n);
|
||||
}
|
||||
n.textContent = text;
|
||||
n.classList.toggle('bad', !!bad);
|
||||
n.classList.remove('fade');
|
||||
void n.offsetWidth;
|
||||
n.classList.add('fade');
|
||||
}
|
||||
|
||||
table.addEventListener('change', function (ev) {
|
||||
var tr = ev.target.closest('tr'), id = tr && tr.dataset.id, el = ev.target;
|
||||
if (!id) return;
|
||||
var body = {}, undo;
|
||||
if (el.classList.contains('u-role')) {
|
||||
body.role = el.value;
|
||||
undo = function () { el.value = el.value === 'user' ? 'admin' : 'user'; };
|
||||
} else if (el.classList.contains('u-pw')) {
|
||||
body.password_off = !el.checked;
|
||||
undo = function () { el.checked = !el.checked; };
|
||||
} else if (el.classList.contains('u-lock')) {
|
||||
body.pw_locked = el.checked;
|
||||
undo = function () { el.checked = !el.checked; };
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
save(id, body).then(function (r) {
|
||||
if (!r.ok) { undo(); note(tr, r.error || 'не сохранилось', true); return; }
|
||||
note(tr, 'сохранено');
|
||||
if (body.role) {
|
||||
var user = body.role === 'user';
|
||||
tr.querySelector('.u-agents').hidden = !user;
|
||||
tr.querySelector('.u-count').hidden = !user;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---- the machines of a user ----
|
||||
var dlg = $('uadlg'), list = $('ualist'), cur = null;
|
||||
function render(filter) {
|
||||
var f = (filter || '').toLowerCase();
|
||||
list.querySelectorAll('label').forEach(function (l) {
|
||||
l.hidden = f && l.textContent.toLowerCase().indexOf(f) < 0;
|
||||
});
|
||||
}
|
||||
table.addEventListener('click', function (ev) {
|
||||
var b = ev.target.closest('.u-agents');
|
||||
if (!b) return;
|
||||
var tr = b.closest('tr');
|
||||
cur = tr;
|
||||
$('uaname').textContent = tr.dataset.name;
|
||||
list.innerHTML = '<div class="muted">загрузка…</div>';
|
||||
$('uafilter').value = '';
|
||||
dlg.showModal();
|
||||
fetch('/api/panel/users/' + tr.dataset.id + '/agents', {cache: 'no-store'}).then(function (r) { return r.json(); }).then(function (d) {
|
||||
list.innerHTML = '';
|
||||
d.agents.forEach(function (a) {
|
||||
var l = document.createElement('label');
|
||||
l.className = 'ua-item';
|
||||
l.innerHTML = '<input type="checkbox"><span class="dot ' + (a.online ? 'on' : 'off') + '"></span><span class="ua-name"></span><span class="muted ua-host"></span>';
|
||||
l.querySelector('input').value = a.id;
|
||||
l.querySelector('input').checked = a.on;
|
||||
l.querySelector('.ua-name').textContent = a.name;
|
||||
l.querySelector('.ua-host').textContent = a.hostname;
|
||||
list.appendChild(l);
|
||||
});
|
||||
if (!d.agents.length) list.innerHTML = '<div class="muted">машин пока нет</div>';
|
||||
});
|
||||
});
|
||||
$('uafilter').addEventListener('input', function (ev) { render(ev.target.value); });
|
||||
$('uaall').onclick = function () { list.querySelectorAll('label:not([hidden]) input').forEach(function (i) { i.checked = true; }); };
|
||||
$('uanone').onclick = function () { list.querySelectorAll('label:not([hidden]) input').forEach(function (i) { i.checked = false; }); };
|
||||
$('uaclose').onclick = $('uacancel').onclick = function () { dlg.close(); };
|
||||
$('uasave').onclick = function () {
|
||||
var ids = [].map.call(list.querySelectorAll('input:checked'), function (i) { return i.value; });
|
||||
save(cur.dataset.id, {agents: ids}).then(function (r) {
|
||||
if (!r.ok) { note(cur, r.error || 'не сохранилось', true); return; }
|
||||
cur.querySelector('.u-count').textContent = ids.length;
|
||||
note(cur, 'машин: ' + ids.length);
|
||||
dlg.close();
|
||||
});
|
||||
};
|
||||
|
||||
// ---- a new user: the machines appear for a plain user ----
|
||||
var role = $('newrole'), pick = $('newagents');
|
||||
if (role) role.onchange = function () { pick.hidden = role.value !== 'user'; };
|
||||
})();
|
||||
@@ -3,49 +3,58 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Машины — LPRA</title>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
<title>Машины - LPRA</title>
|
||||
<link rel="stylesheet" href="{{asset "app.css"}}">
|
||||
<script src="{{asset "pin.js"}}"></script>
|
||||
<script src="{{asset "scroll.js"}}" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand">LPRA · Удалённый доступ</div>
|
||||
<div class="brand"><a class="home" href="/" title="К списку машин">LPRA</a> · Удалённый доступ</div>
|
||||
<nav>
|
||||
<a href="/">Машины</a>
|
||||
<a href="/settings">Настройки</a>
|
||||
<form method="post" action="/logout" style="display:inline; margin-left:22px">
|
||||
<button class="btn ghost small" type="submit">Выйти</button>
|
||||
</form>
|
||||
{{if .IsAdmin}}<a href="/settings">Настройки</a>{{end}}
|
||||
<button type="button" class="btn ghost icon acct-btn" id="acct" title="Аккаунт" aria-label="Аккаунт"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/></svg></button>
|
||||
</nav>
|
||||
</header>
|
||||
<div class="wrap">
|
||||
<h1>Машины-клиенты</h1>
|
||||
<div class="card">
|
||||
{{if .Agents}}
|
||||
<table>
|
||||
<thead><tr><th>Статус</th><th>Имя</th><th>Hostname</th><th>ОС / Арх</th><th>Активность</th><th></th></tr></thead>
|
||||
<table class="stack">
|
||||
<thead><tr><th>Статус</th><th>Имя</th><th>Hostname</th><th>ОС / Арх</th><th>Активность</th><th>Действия</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Agents}}
|
||||
<tr>
|
||||
<td><span class="dot {{if .Online}}on{{else}}off{{end}}"></span>{{if .Online}}online{{else}}offline{{end}}</td>
|
||||
<td>{{.Name}}</td>
|
||||
<td class="muted">{{.Hostname}}</td>
|
||||
<td class="muted">{{.OS}}/{{.Arch}}</td>
|
||||
<td class="muted">{{.Seen}}</td>
|
||||
<td>
|
||||
<tr data-agent="{{.ID}}">
|
||||
<td class="nowrap" data-l="Статус"><span class="dot {{if .Online}}on{{else}}off{{end}}"></span><span class="state">{{if .Online}}online{{else}}offline{{end}}</span></td>
|
||||
<td class="lead">{{.Name}}</td>
|
||||
<td class="muted" data-l="Hostname">{{.Hostname}}</td>
|
||||
<td class="muted nowrap" data-l="ОС / Арх">{{.OS}}/{{.Arch}}</td>
|
||||
<td class="muted nowrap seen" data-l="Активность">{{.Seen}}</td>
|
||||
<td class="actions">
|
||||
<div class="row" style="gap:6px">
|
||||
<a class="btn small" href="/agents/{{.ID}}/terminal">Терминал</a>
|
||||
<a class="btn ghost small" href="/agents/{{.ID}}/files">Файлы</a>
|
||||
<a class="btn ghost small" href="/agents/{{.ID}}/forwards">Порты</a>
|
||||
<a class="btn ghost small icon" href="/agents/{{.ID}}/edit" title="Изменить">
|
||||
<a class="btn small icon" href="/agents/{{.ID}}/terminal" data-tip="Терминал" aria-label="Терминал">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>
|
||||
</a>
|
||||
<a class="btn ghost small icon" href="/agents/{{.ID}}/files" data-tip="Файлы" aria-label="Файлы">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
|
||||
</a>
|
||||
<a class="btn ghost small icon" href="/agents/{{.ID}}/forwards" data-tip="Порты" aria-label="Порты">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>
|
||||
</a>
|
||||
<a class="btn ghost small icon" href="/agents/{{.ID}}/status" data-tip="Статус" aria-label="Статус">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
|
||||
</a>
|
||||
<a class="btn ghost small icon" href="/agents/{{.ID}}/edit" data-tip="Изменить" aria-label="Изменить">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z"/></svg>
|
||||
</a>
|
||||
<form method="post" action="/agents/{{.ID}}/delete" style="display:inline"
|
||||
onsubmit="return confirm('Удалить машину {{.Name}}? Сервис на ней продолжит работать, но перестанет быть виден. Чтобы снова подключиться — переустановите агента.')">
|
||||
{{if $.IsAdmin}}<form method="post" action="/agents/{{.ID}}/delete" style="display:inline"
|
||||
onsubmit="return confirm('Удалить машину {{.Name}}? Сервис на ней продолжит работать, но перестанет быть виден. Чтобы снова подключиться - переустановите агента.')">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<button class="btn danger small icon" type="submit" title="Удалить">
|
||||
<button class="btn danger small icon" type="submit" data-tip="Удалить" aria-label="Удалить">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>
|
||||
</button>
|
||||
</form>
|
||||
</form>{{end}}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -53,10 +62,45 @@
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="muted">Пока нет подключённых машин. Добавьте первую в разделе
|
||||
<a href="/settings">Настройки</a> — там одна команда для установки.</p>
|
||||
{{if .IsAdmin}}<p class="muted">Пока нет подключённых машин. Добавьте первую в разделе
|
||||
<a href="/settings">Настройки</a> - там одна команда для установки.</p>
|
||||
{{else}}<p class="muted">Вам пока не выдали ни одной машины - попросите администратора.</p>{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
// Keep the status column live; nothing is asked while the tab is hidden.
|
||||
(function () {
|
||||
var rows = {};
|
||||
document.querySelectorAll('tr[data-agent]').forEach(function (tr) { rows[tr.dataset.agent] = tr; });
|
||||
var timer = null, busy = false;
|
||||
function schedule() {
|
||||
clearTimeout(timer);
|
||||
if (!document.hidden) timer = setTimeout(load, 5000);
|
||||
}
|
||||
function load() {
|
||||
if (busy || document.hidden) return;
|
||||
busy = true;
|
||||
fetch('/api/panel/agents/presence', {credentials: 'same-origin'})
|
||||
.then(function (r) { return r.ok ? r.json() : []; })
|
||||
.then(function (list) {
|
||||
list.forEach(function (a) {
|
||||
var tr = rows[a.id];
|
||||
if (!tr) return;
|
||||
tr.querySelector('.dot').className = 'dot ' + (a.online ? 'on' : 'off');
|
||||
tr.querySelector('.state').textContent = a.online ? 'online' : 'offline';
|
||||
tr.querySelector('.seen').textContent = a.seen;
|
||||
});
|
||||
})
|
||||
.catch(function () {})
|
||||
.then(function () { busy = false; schedule(); });
|
||||
}
|
||||
document.addEventListener('visibilitychange', function () {
|
||||
if (document.hidden) clearTimeout(timer); else load();
|
||||
});
|
||||
if (Object.keys(rows).length) schedule();
|
||||
})();
|
||||
</script>
|
||||
<script src="{{asset "account.js"}}"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -3,14 +3,20 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Изменить машину — LPRA</title>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
<title>Изменить машину - LPRA</title>
|
||||
<link rel="stylesheet" href="{{asset "app.css"}}">
|
||||
<script src="{{asset "pin.js"}}"></script>
|
||||
<script src="{{asset "scroll.js"}}" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand">LPRA · Изменить машину</div>
|
||||
<div class="brand"><a class="home" href="/" title="К списку машин">LPRA</a> · Изменить машину</div>
|
||||
<nav>
|
||||
<a href="/">← Машины</a>
|
||||
<a href="/agents/{{.Agent.ID}}/terminal">Терминал</a>
|
||||
<a href="/agents/{{.Agent.ID}}/files">Файлы</a>
|
||||
<a href="/agents/{{.Agent.ID}}/forwards">Порты</a>
|
||||
<a href="/agents/{{.Agent.ID}}/status">Статус</a>
|
||||
</nav>
|
||||
</header>
|
||||
<div class="wrap">
|
||||
@@ -28,9 +34,69 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Пользователь</h2>
|
||||
{{if .UserOK}}<div class="err" style="background:#1d3a24;border-color:#2b6b3a;color:#b3ffcb">{{.UserOK}}</div>{{end}}
|
||||
{{if .UserErr}}<div class="err">{{.UserErr}}</div>{{end}}
|
||||
<p class="muted">
|
||||
Клиент запущен от: <code>{{if .Users}}{{.Users.Current}}{{else if .Agent.RunUser}}{{.Agent.RunUser}}{{else}}неизвестно{{end}}</code>
|
||||
· Терминал открывается от: <code>{{.Agent.TerminalUser}}</code>
|
||||
</p>
|
||||
{{if .Users}}
|
||||
<form method="post" action="/agents/{{.Agent.ID}}/user" class="row">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<select name="user" {{if not .Users.CanSwitch}}disabled{{end}}>
|
||||
{{range .Users.Users}}<option value="{{.}}" {{if eq . $.Agent.TerminalUser}}selected{{end}}>{{.}}</option>{{end}}
|
||||
</select>
|
||||
<button type="submit" {{if not .Users.CanSwitch}}disabled{{end}}>Сохранить</button>
|
||||
{{if .Users.CanSwitch}}<button type="button" class="btn ghost" onclick="document.getElementById('newuser').hidden = !document.getElementById('newuser').hidden">Создать нового</button>{{end}}
|
||||
</form>
|
||||
{{if .Users.CanSwitch}}
|
||||
<form id="newuser" method="post" action="/agents/{{.Agent.ID}}/users/add" style="max-width:360px; margin-top:16px" hidden>
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<label for="nu-name">Имя нового пользователя</label>
|
||||
<input type="text" id="nu-name" name="name" pattern="[a-z_][a-z0-9_\-]{0,31}" autocomplete="off" required>
|
||||
<label for="nu-pass">Пароль</label>
|
||||
<input type="password" id="nu-pass" name="password" autocomplete="new-password">
|
||||
<label style="color:var(--text); font-size:14px">
|
||||
<input type="checkbox" name="sudo" value="1"> Добавить в sudo
|
||||
</label>
|
||||
<p class="muted" style="font-size:13px">Без пароля вход по паролю (и sudo) для него работать не будет - только терминал из панели.</p>
|
||||
<button type="submit">Создать пользователя</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<p class="muted" style="font-size:13px; margin-top:12px">Клиент запущен не от root, поэтому сменить или создать пользователя нельзя.</p>
|
||||
{{end}}
|
||||
{{else if .UsersErr}}
|
||||
<p class="muted" style="font-size:13px">Список пользователей недоступен: {{.UsersErr}}.</p>
|
||||
{{else}}
|
||||
<p class="muted" style="font-size:13px">Машина офлайн - сменить пользователя можно, когда она на связи.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Связь с сервером</h2>
|
||||
<p class="muted">
|
||||
Сейчас: {{if not .Online}}<span>офлайн</span>{{else if .OnWS}}<span style="color:var(--ok)">WebSocket</span>{{else}}<span>long polling</span>{{end}}{{if and .Online .ViaDirect}} <span class="muted">(direct)</span>{{end}}
|
||||
</p>
|
||||
<form method="post" action="/agents/{{.Agent.ID}}/transport" class="row">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<select name="transport">
|
||||
<option value="auto" {{if eq .Transport "auto"}}selected{{end}}>Авто: WebSocket, а если не выходит - long polling</option>
|
||||
<option value="ws" {{if eq .Transport "ws"}}selected{{end}}>Только WebSocket</option>
|
||||
<option value="lp" {{if eq .Transport "lp"}}selected{{end}}>Только long polling</option>
|
||||
</select>
|
||||
<button type="submit">Сохранить</button>
|
||||
</form>
|
||||
<p class="muted" style="font-size:13px; margin-top:10px">WebSocket держит одно соединение: команды и терминал идут по нему без отдельного запроса на каждое нажатие.
|
||||
В режиме «Авто» клиент, потеряв WebSocket, переходит на long polling и раз в минуту пробует вернуться.
|
||||
Нужен клиент 0.1.0+ и прокси, пропускающий WebSocket (nginx: заголовки Upgrade и Connection).</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<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">Клиент {{if .UpdateMsg}}{{.UpdateMsg}}{{else}}обновлён и перезапускается{{end}}. Через несколько секунд он вернётся с новой версией.</div>{{end}}
|
||||
{{if .UpdateErr}}<div class="err">Обновление не вышло: {{.UpdateErr}}</div>{{end}}
|
||||
<p class="muted">
|
||||
Клиент: <code>{{if .Agent.Version}}{{.Agent.Version}}{{else}}неизвестно{{end}}</code>
|
||||
· Сервер: <code>{{.ServerVersion}}</code>
|
||||
@@ -47,16 +113,16 @@
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
{{if .IsAdmin}}<div class="card">
|
||||
<h2>Удаление</h2>
|
||||
<p class="muted">Удаляет машину из панели. Сервис на самой машине продолжит работать,
|
||||
но перестанет быть виден; чтобы подключиться снова — переустановите агента.</p>
|
||||
но перестанет быть виден; чтобы подключиться снова - переустановите агента.</p>
|
||||
<form method="post" action="/agents/{{.Agent.ID}}/delete"
|
||||
onsubmit="return confirm('Удалить машину {{.Agent.Name}}?')">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<button class="btn danger" type="submit">Удалить машину</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>{{end}}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -3,49 +3,71 @@
|
||||
<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">
|
||||
<title>Порты · {{.Agent.Name}} - LPRA</title>
|
||||
<link rel="stylesheet" href="{{asset "app.css"}}">
|
||||
<script src="{{asset "pin.js"}}"></script>
|
||||
<script src="{{asset "scroll.js"}}" defer></script>
|
||||
<style>
|
||||
.frow { display:flex; align-items:flex-end; gap:14px; flex-wrap:wrap; }
|
||||
.frow label { margin:0 0 6px; }
|
||||
.frow .fld { display:flex; flex-direction:column; }
|
||||
.frow select, .frow input, .frow button {
|
||||
/* One line: fixed narrow fields, the label takes what is left. */
|
||||
.frow { display:flex; align-items:flex-end; gap:12px; flex-wrap:wrap; }
|
||||
.frow label { margin:0 0 6px; white-space:nowrap; }
|
||||
.frow .fld { display:flex; flex-direction:column; flex:none; width:108px; }
|
||||
.frow .fld.addr { width:128px; }
|
||||
.frow .fld.grow { flex:1; width:auto; min-width:150px; }
|
||||
.frow select, .frow input[type=text], .frow button {
|
||||
height:40px; box-sizing:border-box; font-size:14px; line-height:1; margin:0;
|
||||
}
|
||||
.frow select {
|
||||
padding:0 32px 0 12px; border-radius:8px; border:1px solid var(--border);
|
||||
background:#0c0f13; color:var(--text);
|
||||
.frow select { min-width:0; width:100%; padding:0 30px 0 12px; background-position:right 10px center; }
|
||||
/* The ufw switch hangs under the button, centred on it, level with the note. */
|
||||
.frow .act { position:relative; }
|
||||
.frow .act label.check {
|
||||
position:absolute; top:100%; left:50%; transform:translateX(-50%);
|
||||
display:inline-flex; align-items:center; gap:8px; height:20px; margin:12px 0 0;
|
||||
color:var(--text); font-size:14px; cursor:pointer;
|
||||
}
|
||||
.under { min-height:20px; margin:12px 110px 0 0; font-size:13px; line-height:20px; }
|
||||
/* Phones: two fields a line, the label and the button each a line of their own. */
|
||||
@media (max-width: 640px) {
|
||||
.frow .fld, .frow .fld.addr { width:calc(50% - 6px); }
|
||||
.frow .fld.grow { flex:1 1 100%; }
|
||||
.frow .act { display:flex; align-items:center; gap:16px; width:100%; }
|
||||
.frow .act button { flex:1; }
|
||||
.frow .act label.check { position:static; transform:none; margin:0; }
|
||||
.under { margin-right:0; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand">LPRA · Порты — {{.Agent.Name}} <span class="muted">({{.Agent.Hostname}})</span></div>
|
||||
<div class="brand"><a class="home" href="/" title="К списку машин">LPRA</a> · Порты - {{.Agent.Name}} <span class="muted">({{.Agent.Hostname}})</span></div>
|
||||
<nav>
|
||||
<a href="/">← Машины</a>
|
||||
<a href="/agents/{{.Agent.ID}}/terminal">Терминал</a>
|
||||
<a href="/agents/{{.Agent.ID}}/files">Файлы</a>
|
||||
<a href="/agents/{{.Agent.ID}}/status">Статус</a>
|
||||
<a href="/agents/{{.Agent.ID}}/edit">Изменить</a>
|
||||
</nav>
|
||||
</header>
|
||||
<div class="wrap">
|
||||
<h1>Проброс портов</h1>
|
||||
<p class="muted">Порт сервиса на машине-клиенте становится доступен на порту хоста.
|
||||
Клиент за NAT — трафик идёт исходящим подключением агента.</p>
|
||||
Клиент за NAT - трафик идёт исходящим подключением агента.</p>
|
||||
{{if .Error}}<div class="err">{{.Error}}</div>{{end}}
|
||||
|
||||
<div class="card">
|
||||
<h2>Правила</h2>
|
||||
{{if .Rules}}
|
||||
<table>
|
||||
<thead><tr><th>Тип</th><th>Хост (порт)</th><th>→ Клиент (порт)</th><th>Слушать на</th><th></th></tr></thead>
|
||||
<table class="stack">
|
||||
<thead><tr><th>Тип</th><th>Хост (порт)</th><th>→ Клиент (порт)</th><th>Слушать на</th><th>Метка</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Rules}}
|
||||
<tr>
|
||||
<td><code>{{.Proto}}</code></td>
|
||||
<td class="muted">:{{.HostPort}}</td>
|
||||
<td class="muted">127.0.0.1:{{.ClientPort}}</td>
|
||||
<td class="muted">{{.ListenAddr}}</td>
|
||||
<td style="text-align:right">
|
||||
<td data-l="Тип"><code>{{.Proto}}</code></td>
|
||||
<td class="muted" data-l="Хост">:{{.HostPort}}</td>
|
||||
<td class="muted" data-l="→ Клиент">127.0.0.1:{{.ClientPort}}</td>
|
||||
<td class="muted" data-l="Слушать на">{{.ListenAddr}}{{if and .NoUfw $.UfwEnabled}} · без ufw{{end}}</td>
|
||||
<td class="lead">{{.Label}}</td>
|
||||
<td class="actions" style="text-align:right">
|
||||
<form method="post" action="/forwards/{{.ID}}/delete" style="display:inline"
|
||||
onsubmit="return confirm('Удалить правило?')">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
@@ -69,44 +91,55 @@
|
||||
<div class="fld">
|
||||
<label>Тип</label>
|
||||
<select name="proto" id="proto">
|
||||
<option value="http">http (long-poll, работает везде)</option>
|
||||
<option value="tcp">tcp (прямой, низкая задержка, порт 8446)</option>
|
||||
<option value="udp">udp (прямой, порт 8446)</option>
|
||||
<option value="http">http-lp</option>
|
||||
<option value="tcp">tcp</option>
|
||||
<option value="udp">udp</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="fld">
|
||||
<label>Порт на клиенте</label>
|
||||
<input type="text" name="client_port" placeholder="напр. 22" style="max-width:140px" required>
|
||||
<label>Порт клиента</label>
|
||||
<input type="text" name="client_port" placeholder="22" required>
|
||||
</div>
|
||||
<div class="fld">
|
||||
<label>Порт на хосте</label>
|
||||
<input type="text" name="host_port" placeholder="напр. 2222" style="max-width:140px" required>
|
||||
<label>Порт хоста</label>
|
||||
<input type="text" name="host_port" placeholder="2222" required>
|
||||
</div>
|
||||
<div class="fld">
|
||||
<label>Слушать на хосте</label>
|
||||
<input type="text" name="listen_addr" value="0.0.0.0" style="max-width:160px">
|
||||
<div class="fld addr">
|
||||
<label>Слушать на</label>
|
||||
<input type="text" name="listen_addr" value="0.0.0.0">
|
||||
</div>
|
||||
<div class="fld grow">
|
||||
<label>Метка</label>
|
||||
<input type="text" name="label" maxlength="80" placeholder="необязательно">
|
||||
</div>
|
||||
<div class="act">
|
||||
<button type="submit">Создать</button>
|
||||
{{if .UfwEnabled}}
|
||||
<label class="check" title="Порт не будет открыт в ufw, и при удалении правила в ufw ничего не изменится">
|
||||
<input type="checkbox" name="no_ufw" value="1"> Без ufw
|
||||
</label>
|
||||
{{end}}
|
||||
</div>
|
||||
<button type="submit">Создать</button>
|
||||
</div>
|
||||
<div class="under"><span class="muted" id="note"></span></div>
|
||||
</form>
|
||||
<p class="muted" id="note" style="font-size:13px; margin-top:12px"></p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Режимы</h2>
|
||||
<ul class="muted" style="font-size:14px; line-height:1.7">
|
||||
<li><b>http</b> — туннель поверх HTTP long-poll (через nginx :8445). Работает везде, где доступна панель, но задержка выше.</li>
|
||||
<li><b>tcp</b> — прямой TCP-мультиплексор через порт <b>8446</b> хоста. Минимальная задержка, keep-alive, TCP_NODELAY. Нужно открыть 8446/tcp на хосте.</li>
|
||||
<li><b>udp</b> — прямой UDP через порт <b>8446</b> хоста. Нужно открыть 8446/udp.</li>
|
||||
<li><b>http-lp</b> - туннель поверх HTTP long-poll (через адрес панели, порт {{.PanelPort}}). Работает везде, где доступна панель, но задержка выше.</li>
|
||||
<li><b>tcp</b> - прямой TCP-мультиплексор через порт <b>{{.TunnelPort}}</b> хоста. Минимальная задержка, keep-alive, TCP_NODELAY. Нужно открыть {{.TunnelPort}}/tcp на хосте.</li>
|
||||
<li><b>udp</b> - прямой UDP через порт <b>{{.TunnelPort}}</b> хоста. Нужно открыть {{.TunnelPort}}/udp.</li>
|
||||
</ul>
|
||||
<p class="muted" style="font-size:13px">Для tcp/udp агент держит исходящее соединение к <code>host:8446</code>; несколько клиентов и портов мультиплексируются на нём.</p>
|
||||
<p class="muted" style="font-size:13px">Для tcp/udp агент держит исходящее соединение к <code>host:{{.TunnelPort}}</code>; несколько клиентов и портов мультиплексируются на нём.</p>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
var notes = {
|
||||
http: 'Трафик идёт через :8445 (long-poll). Порт 8446 не требуется.',
|
||||
tcp: 'Требуется открытый порт 8446/tcp на хосте. Самая низкая задержка.',
|
||||
udp: 'Требуется открытый порт 8446/udp на хосте.'
|
||||
http: 'Трафик идёт через порт панели {{.PanelPort}} (long-poll). Порт {{.TunnelPort}} не требуется.',
|
||||
tcp: 'Требуется открытый порт {{.TunnelPort}}/tcp на хосте. Самая низкая задержка.',
|
||||
udp: 'Требуется открытый порт {{.TunnelPort}}/udp на хосте.'
|
||||
};
|
||||
var sel = document.getElementById('proto'), note = document.getElementById('note');
|
||||
function upd(){ note.textContent = notes[sel.value] || ''; }
|
||||
|
||||
@@ -3,22 +3,68 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Вход — LPRA</title>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
<title>Вход - LPRA</title>
|
||||
<link rel="stylesheet" href="{{asset "app.css"}}">
|
||||
</head>
|
||||
<body>
|
||||
<div class="center">
|
||||
<div class="card">
|
||||
<h1>Вход в панель</h1>
|
||||
{{if .Error}}<div class="err">{{.Error}}</div>{{end}}
|
||||
{{if .Error}}<div class="err" id="err">{{.Error}}</div>{{else}}<div class="err" id="err" hidden></div>{{end}}
|
||||
<form method="post" action="/login">
|
||||
<label>Имя пользователя</label>
|
||||
<input type="text" name="username" autofocus required>
|
||||
<label>Пароль</label>
|
||||
<input type="password" name="password" required>
|
||||
<div style="margin-top:18px"><button type="submit">Войти</button></div>
|
||||
<label for="u">Имя пользователя</label>
|
||||
<input type="text" id="u" name="username" value="{{.Username}}" autocomplete="username webauthn" autofocus required>
|
||||
<label for="pw">Пароль</label>
|
||||
<input type="password" id="pw" name="password" autocomplete="current-password" required>
|
||||
<div style="margin-top:18px"><button type="submit" style="width:100%">Войти</button></div>
|
||||
</form>
|
||||
<div class="or" id="or" hidden>или</div>
|
||||
<button type="button" class="keybtn btn ghost" id="keylogin" hidden><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="7.5" cy="15.5" r="4.5"/><path d="m21 2-9.6 9.6M15.5 7.5l3 3L22 7l-3-3"/></svg>Войти с ключом доступа</button>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
// Sign in with a passkey: with the name typed, its keys are offered; without
|
||||
// it, the device offers the keys it keeps for this site.
|
||||
(function () {
|
||||
if (!(window.PublicKeyCredential && navigator.credentials && window.isSecureContext)) return;
|
||||
var btn = document.getElementById('keylogin'), err = document.getElementById('err');
|
||||
btn.hidden = false;
|
||||
document.getElementById('or').hidden = false;
|
||||
var enc = function (buf) {
|
||||
var s = '', b = new Uint8Array(buf);
|
||||
for (var i = 0; i < b.length; i++) s += String.fromCharCode(b[i]);
|
||||
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
};
|
||||
var dec = function (str) {
|
||||
var s = atob(str.replace(/-/g, '+').replace(/_/g, '/') + '==='.slice((str.length + 3) % 4)), b = new Uint8Array(s.length);
|
||||
for (var i = 0; i < s.length; i++) b[i] = s.charCodeAt(i);
|
||||
return b.buffer;
|
||||
};
|
||||
function show(t) { err.textContent = t; err.hidden = !t; }
|
||||
btn.onclick = function () {
|
||||
btn.disabled = true;
|
||||
show('');
|
||||
fetch('/api/login/key/begin', {method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({username: document.getElementById('u').value})})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
var o = r.options;
|
||||
o.challenge = dec(o.challenge);
|
||||
o.allowCredentials.forEach(function (c) { c.id = dec(c.id); });
|
||||
return navigator.credentials.get({publicKey: o});
|
||||
})
|
||||
.then(function (c) {
|
||||
return fetch('/api/login/key/finish', {method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({id: enc(c.rawId), clientDataJSON: enc(c.response.clientDataJSON),
|
||||
authenticatorData: enc(c.response.authenticatorData), signature: enc(c.response.signature)})})
|
||||
.then(function (r) { return r.json(); });
|
||||
})
|
||||
.then(function (r) { if (!r.ok) throw new Error(r.error); location.href = '/'; })
|
||||
.catch(function (e) { show(e && e.name === 'NotAllowedError' ? 'Вход по ключу отменён.' : (e && e.message) || 'Не вышло.'); })
|
||||
.then(function () { btn.disabled = false; });
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -3,22 +3,64 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Настройки — LPRA</title>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
<title>Настройки - LPRA</title>
|
||||
<link rel="stylesheet" href="{{asset "app.css"}}">
|
||||
<script src="{{asset "pin.js"}}"></script>
|
||||
<script src="{{asset "scroll.js"}}" defer></script>
|
||||
<style>
|
||||
/* An install command, with a button to copy it at its right. */
|
||||
/* Users: role, machines, password sign-in. */
|
||||
.tablewrap { overflow-x:auto; }
|
||||
table.users th { font-size:12px; font-weight:500; color:var(--muted); text-align:left; white-space:nowrap; }
|
||||
table.users td { vertical-align:middle; }
|
||||
table.users select.u-role { width:auto; height:30px; padding:2px 30px 2px 8px; font-size:13px; }
|
||||
table.users .u-agents { vertical-align:middle; margin-left:4px; }
|
||||
table.users .u-count { font-size:12px; margin-left:2px; }
|
||||
table.users .u-lock { width:16px; height:16px; cursor:pointer; }
|
||||
.u-note { font-size:12px; color:var(--ok); opacity:0; }
|
||||
.u-note.bad { color:var(--danger); }
|
||||
.u-note.fade { animation:u-note 3s ease forwards; }
|
||||
@keyframes u-note { 0%, 70% { opacity:1; } 100% { opacity:0; } }
|
||||
.u-add { margin-top:14px; }
|
||||
.u-pick { display:flex; flex-wrap:wrap; gap:6px; align-items:center; animation:u-in .2s ease; }
|
||||
.u-pick[hidden] { display:none; }
|
||||
.u-pick > .muted:first-child { flex-basis:100%; }
|
||||
.u-chip { display:inline-flex; align-items:center; gap:6px; margin:0; padding:5px 10px; border:1px solid var(--border); border-radius:16px;
|
||||
font-size:13px; color:var(--text); cursor:pointer; transition:border-color .15s, background .15s; }
|
||||
.u-chip:has(input:checked) { border-color:var(--accent); background:#12202f; }
|
||||
.u-chip input { width:14px; height:14px; margin:0; }
|
||||
@keyframes u-in { from { opacity:0; transform:translateY(-4px); } }
|
||||
#uadlg { max-width:480px; }
|
||||
.ua-tools { display:flex; gap:8px; margin-bottom:10px; }
|
||||
.ua-tools input { flex:1; height:32px; }
|
||||
.ua-list { max-height:50vh; overflow:auto; border:1px solid var(--border); border-radius:9px; padding:4px; }
|
||||
.ua-item { display:flex; align-items:center; gap:10px; margin:0; padding:8px 10px; border-radius:7px; cursor:pointer; color:var(--text); font-size:14px; }
|
||||
.ua-item:hover { background:#151a21; }
|
||||
.ua-item[hidden] { display:none; }
|
||||
.ua-item input { width:16px; height:16px; margin:0; }
|
||||
.ua-host { margin-left:auto; font-size:12px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.copybox { position:relative; }
|
||||
.copybox pre { padding-right:48px; }
|
||||
.copybox .copy { position:absolute; top:7px; right:7px; width:30px; height:30px; padding:0;
|
||||
display:inline-flex; align-items:center; justify-content:center; background:var(--panel); }
|
||||
.copybox .copy.done { color:var(--ok); border-color:var(--ok); }
|
||||
.codeform { display:flex; flex-wrap:wrap; align-items:center; gap:8px 10px; margin-top:10px; font-size:13px; }
|
||||
.codeform label { margin:0; }
|
||||
.codeform input[type=number] { width:72px; height:30px; padding:4px 8px; }
|
||||
.codeform .ico { display:inline-flex; align-items:center; gap:6px; height:30px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand">LPRA · Удалённый доступ</div>
|
||||
<div class="brand"><a class="home" href="/" title="К списку машин">LPRA</a> · Удалённый доступ</div>
|
||||
<nav>
|
||||
<a href="/">Машины</a>
|
||||
<a href="/settings">Настройки</a>
|
||||
<form method="post" action="/logout" style="display:inline; margin-left:22px">
|
||||
<button class="btn ghost small" type="submit">Выйти</button>
|
||||
</form>
|
||||
<button type="button" class="btn ghost icon acct-btn" id="acct" title="Аккаунт" aria-label="Аккаунт"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/></svg></button>
|
||||
</nav>
|
||||
</header>
|
||||
<div class="wrap">
|
||||
<h1>Настройки</h1>
|
||||
{{if .Error}}<div class="err">{{.Error}}</div>{{end}}
|
||||
|
||||
<div class="card">
|
||||
<h2>Установка на новую машину</h2>
|
||||
@@ -26,14 +68,29 @@
|
||||
поставит сервис и зарегистрирует машину.</p>
|
||||
|
||||
<label>Короткая команда (секрет уже вшит в ссылку)</label>
|
||||
<pre id="short">{{.InstallShort}}</pre>
|
||||
<div class="copybox"><pre id="short">{{.InstallShort}}</pre><button type="button" class="btn ghost small icon sq copy" data-copy="short" title="Скопировать" 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"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"/></svg></button></div>
|
||||
|
||||
<form method="post" action="/settings/install/code" class="codeform"
|
||||
onsubmit="return confirm('Сделать новый код? Старая короткая ссылка перестанет работать.')">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<label for="codelen">Символов в коде</label>
|
||||
<input type="number" id="codelen" name="len" min="3" max="24" value="{{.CodeLen}}">
|
||||
<button type="submit" class="btn ghost small ico"><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="M21 12a9 9 0 1 1-3-6.7L21 8"/><path d="M21 3v5h-5"/></svg>Новый код</button>
|
||||
<span class="muted">латиница и цифры, от 3 до 24; новый код отменяет старую ссылку</span>
|
||||
</form>
|
||||
|
||||
<details style="margin-top:10px">
|
||||
<summary class="muted" style="cursor:pointer">Полные варианты (секрет в команде)</summary>
|
||||
<label>curl</label>
|
||||
<pre id="curl">{{.InstallCurl}}</pre>
|
||||
<div class="copybox"><pre id="curl">{{.InstallCurl}}</pre><button type="button" class="btn ghost small icon sq copy" data-copy="curl" title="Скопировать" 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"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"/></svg></button></div>
|
||||
<label>wget</label>
|
||||
<pre id="wget">{{.InstallWget}}</pre>
|
||||
<div class="copybox"><pre id="wget">{{.InstallWget}}</pre><button type="button" class="btn ghost small icon sq copy" data-copy="wget" title="Скопировать" 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"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"/></svg></button></div>
|
||||
{{if .Direct}}
|
||||
<label>Короткая через прямой адрес (если основной недоступен)</label>
|
||||
<div class="copybox"><pre id="dshort">{{.DirectShort}}</pre><button type="button" class="btn ghost small icon sq copy" data-copy="dshort" title="Скопировать" 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"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"/></svg></button></div>
|
||||
<label>curl через прямой адрес</label>
|
||||
<div class="copybox"><pre id="dcurl">{{.DirectCurl}}</pre><button type="button" class="btn ghost small icon sq copy" data-copy="dcurl" title="Скопировать" 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"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"/></svg></button></div>
|
||||
{{end}}
|
||||
<p class="muted">Без переменных окружения скрипт спросит секрет интерактивно:
|
||||
<code>curl -fsSL {{.Host}}/install.sh | sudo sh</code></p>
|
||||
</details>
|
||||
@@ -41,14 +98,41 @@
|
||||
|
||||
<div class="card">
|
||||
<h2>Адрес хоста</h2>
|
||||
<p class="muted">URL, на который звонят агенты. Подставляется в скрипт установки.</p>
|
||||
<form method="post" action="/settings/host" class="row">
|
||||
<form method="post" action="/settings/host" class="hostform">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<input type="text" name="host" value="{{.Host}}" style="max-width:420px">
|
||||
<button type="submit">Сохранить</button>
|
||||
<label for="host">Основной</label>
|
||||
<input type="text" id="host" name="host" value="{{.Host}}" placeholder="https://lpra.example.com">
|
||||
<p class="muted">URL, на который звонят агенты. Подставляется в скрипт установки.</p>
|
||||
<label for="direct">Прямой (без Cloudflare) - необязательно</label>
|
||||
<input type="text" id="direct" name="direct" value="{{.Direct}}" placeholder="https://direct.lpra.example.com">
|
||||
<p class="muted">Имя, которое ведёт прямо на сервер, мимо прокси. Агенты переходят на него, если основной
|
||||
недоступен 30 секунд, и возвращаются, когда он ответит; уже установленные получат его сами. Установщик
|
||||
качает агента с него, если основной не отвечает. Если хост туннеля ниже не задан, туннель тоже идёт на этот хост.
|
||||
Ключи доступа с основного домена работают на прямом, если он его поддомен (direct.lpra.example.com).</p>
|
||||
<div><button type="submit">Сохранить</button></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Прямой туннель (tcp/udp проброс)</h2>
|
||||
{{if not .TunnelDefault}}
|
||||
<p class="muted">Прямой туннель отключён при запуске сервера (пустой <code>-tunnel</code> / <code>LPRA_TUNNEL_ADDR</code>).</p>
|
||||
{{else}}
|
||||
<p class="muted">Адрес, к которому агенты подключаются для проброса в режимах tcp/udp. Агенты забирают его
|
||||
с панели сами - при подключении и при каждом опросе, так что смена применяется без переустановки.
|
||||
Хост можно оставить пустым (тогда берётся хост из адреса панели) или указать IP/другое имя.</p>
|
||||
<form method="post" action="/settings/tunnel" class="row">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<input type="text" name="tunnel_host" value="{{.TunnelHost}}" placeholder="хост (как у панели)" style="max-width:260px">
|
||||
<input type="number" name="tunnel_port" min="1" max="65535" value="{{if .TunnelCustom}}{{.TunnelPort}}{{end}}" placeholder="{{.TunnelDefault}}" style="max-width:120px">
|
||||
<button type="submit">Сохранить</button>
|
||||
</form>
|
||||
<p class="muted" style="font-size:13px">Сейчас сервер слушает порт <b>{{.TunnelPort}}</b> (tcp и udp). При смене порта сервер
|
||||
сразу начинает слушать новый, агенты переподключаются в течение ~30 секунд, правило ufw переносится.
|
||||
Агенты старше 0.0.3 и агенты с заданными <code>TUNNEL_ADDR</code>/<code>TUNNEL_PORT</code> адрес с панели не берут.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Enroll-секрет</h2>
|
||||
<p class="muted">Общий секрет для регистрации новых агентов.</p>
|
||||
@@ -63,15 +147,38 @@
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Администраторы</h2>
|
||||
<table>
|
||||
<h2 id="users">Пользователи</h2>
|
||||
<p class="muted" style="font-size:13px">Администратор видит всё. Пользователь - только выданные ему машины, без настроек.
|
||||
«Пароль» - можно ли входить по паролю (выключить можно, когда у человека есть ключ доступа);
|
||||
«Закрепить» - он не сможет сам поменять это в своём аккаунте.</p>
|
||||
<div class="tablewrap"><table class="users">
|
||||
<thead><tr><th>Имя</th><th>Роль</th><th>Вход</th><th title="Вход по паролю">Пароль</th><th title="Не даёт менять вход по паролю самому">Закрепить</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Admins}}
|
||||
<tr>
|
||||
<td>{{.Username}}</td>
|
||||
<td style="text-align:right">
|
||||
<tr data-id="{{.ID}}" data-name="{{.Username}}">
|
||||
<td>{{.Username}}{{if eq .ID $.Me.ID}} <span class="muted">(вы)</span>{{end}}</td>
|
||||
<td class="nowrap">
|
||||
<select class="u-role" aria-label="Роль">
|
||||
<option value="admin"{{if .IsAdmin}} selected{{end}}>Администратор</option>
|
||||
<option value="user"{{if not .IsAdmin}} selected{{end}}>Пользователь</option>
|
||||
</select>
|
||||
<button type="button" class="btn ghost small icon sq u-agents" title="Машины пользователя" aria-label="Машины пользователя"{{if .IsAdmin}} hidden{{end}}><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg></button>
|
||||
<span class="muted u-count"{{if .IsAdmin}} hidden{{end}}>{{index $.AgentCounts .ID}}</span>
|
||||
</td>
|
||||
<td class="muted">{{with index $.KeyCounts .ID}}ключей: {{.}}{{else}}только пароль{{end}}</td>
|
||||
<td><input type="checkbox" class="sw u-pw" aria-label="Вход по паролю"{{if not (index $.PassOff .ID)}} checked{{end}}{{if not (index $.KeyCounts .ID)}} disabled title="Нет ключей доступа - пароль выключить нельзя"{{end}}></td>
|
||||
<td><input type="checkbox" class="u-lock" aria-label="Закрепить"{{if .PwLocked}} checked{{end}}></td>
|
||||
<td style="text-align:right" class="nowrap">
|
||||
{{if index $.KeyCounts .ID}}
|
||||
<form method="post" action="/settings/admins/keys/reset" style="display:inline"
|
||||
onsubmit="return confirm('Сбросить все ключи доступа {{.Username}}? Вход по паролю снова включится.')">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<input type="hidden" name="id" value="{{.ID}}">
|
||||
<button class="btn ghost small" type="submit" title="Удалить все ключи доступа и снова включить вход по паролю">Сбросить ключи</button>
|
||||
</form>
|
||||
{{end}}
|
||||
<form method="post" action="/settings/admins/delete" style="display:inline"
|
||||
onsubmit="return confirm('Удалить администратора {{.Username}}?')">
|
||||
onsubmit="return confirm('Удалить {{.Username}}?')">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<input type="hidden" name="id" value="{{.ID}}">
|
||||
<button class="btn danger small" type="submit">Удалить</button>
|
||||
@@ -80,21 +187,39 @@
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<form method="post" action="/settings/admins/add" style="margin-top:14px">
|
||||
</table></div>
|
||||
<form method="post" action="/settings/admins/add" class="u-add">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<div class="row">
|
||||
<input type="text" name="username" placeholder="логин" style="max-width:200px" required>
|
||||
<input type="password" name="password" placeholder="пароль (мин. 8)" style="max-width:200px" required>
|
||||
<input type="text" name="username" placeholder="логин" style="max-width:200px" required autocomplete="off">
|
||||
<input type="password" name="password" placeholder="пароль (мин. 8)" style="max-width:200px" required autocomplete="new-password">
|
||||
<select name="role" id="newrole" style="max-width:180px">
|
||||
<option value="admin">Администратор</option>
|
||||
<option value="user">Пользователь</option>
|
||||
</select>
|
||||
<button type="submit">Добавить</button>
|
||||
</div>
|
||||
<div class="u-pick" id="newagents" hidden>
|
||||
<div class="muted" style="font-size:13px;margin:10px 0 6px">Машины, к которым у него будет доступ:</div>
|
||||
{{range .AllAgents}}<label class="u-chip"><input type="checkbox" name="agents" value="{{.ID}}"><span>{{.Name}}</span></label>{{else}}<span class="muted">машин пока нет</span>{{end}}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<dialog id="uadlg" tabindex="-1">
|
||||
<div class="dlg-h"><strong style="flex:1">Машины пользователя <span id="uaname"></span></strong>
|
||||
<button type="button" class="btn ghost small icon close x" id="uaclose" title="Закрыть" aria-label="Закрыть"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button></div>
|
||||
<div class="dlg-b">
|
||||
<div class="ua-tools"><input type="text" id="uafilter" placeholder="поиск" autocomplete="off"><button type="button" class="btn ghost small" id="uaall">Все</button><button type="button" class="btn ghost small" id="uanone">Ни одной</button></div>
|
||||
<div class="ua-list" id="ualist"></div>
|
||||
<div class="row" style="justify-content:flex-end;margin-top:14px"><button type="button" class="btn ghost" id="uacancel">Отмена</button><button type="button" id="uasave">Сохранить</button></div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<div class="card">
|
||||
<h2>Firewall (ufw)</h2>
|
||||
{{if not .UfwInstalled}}
|
||||
<p class="muted">ufw не установлен на хосте — интеграция недоступна.</p>
|
||||
<p class="muted">ufw не установлен на хосте - интеграция недоступна.</p>
|
||||
{{else if not .UfwRunnable}}
|
||||
<p class="muted">ufw установлен, но сервер не может им управлять (нужны права root).
|
||||
Запустите сервер под root или настройте sudo для ufw.</p>
|
||||
@@ -103,7 +228,8 @@
|
||||
Статус ufw: {{if .UfwActive}}<span style="color:var(--ok)">активен</span>{{else}}<span style="color:var(--danger)">выключен</span>{{end}}
|
||||
· Интеграция: {{if .UfwEnabled}}<span style="color:var(--ok)">включена</span>{{else}}<span class="muted">выключена</span>{{end}}
|
||||
</p>
|
||||
<p class="muted" style="font-size:13px">При создании правила проброса порт открывается в ufw автоматически (для tcp/udp — также порт 8446).</p>
|
||||
<p class="muted" style="font-size:13px">При создании правила проброса порт открывается в ufw автоматически (для tcp/udp - также порт туннеля{{if .TunnelPort}} {{.TunnelPort}}{{end}}); для http-проброса открывается tcp-порт хоста. Порты правил, слушающих только 127.0.0.1, не открываются.</p>
|
||||
{{if .UfwError}}<div class="err">Последняя команда ufw завершилась ошибкой: <code>{{.UfwError}}</code></div>{{end}}
|
||||
<form method="post" action="/settings/ufw/toggle" style="display:inline">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<input type="hidden" name="enabled" value="{{if .UfwEnabled}}0{{else}}1{{end}}">
|
||||
@@ -119,6 +245,7 @@
|
||||
{{range .UfwRules}}
|
||||
<tr>
|
||||
<td><code>{{.Port}}/{{.Proto}}</code></td>
|
||||
<td class="muted">{{if .Manual}}вручную{{else if .RuleID}}проброс{{else}}туннель{{end}}</td>
|
||||
<td style="text-align:right">
|
||||
<form method="post" action="/settings/ufw/revoke" style="display:inline"
|
||||
onsubmit="return confirm('Отменить правило ufw {{.Port}}/{{.Proto}}?')">
|
||||
@@ -134,10 +261,52 @@
|
||||
{{else}}
|
||||
<p class="muted" style="font-size:13px; margin-top:10px">Панель пока не открывала портов в ufw.</p>
|
||||
{{end}}
|
||||
|
||||
{{if and .UfwActive .UfwEnabled}}
|
||||
<h2 style="margin-top:18px; font-size:15px">Открыть порт вручную</h2>
|
||||
<form method="post" action="/settings/ufw/add" class="row">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<input type="number" name="port" min="1" max="65535" placeholder="порт" style="max-width:120px" required>
|
||||
<select name="proto" style="max-width:100px">
|
||||
<option value="tcp">tcp</option>
|
||||
<option value="udp">udp</option>
|
||||
</select>
|
||||
<button type="submit">Открыть</button>
|
||||
</form>
|
||||
<p class="muted" style="font-size:13px">Такое правило не привязано к пробросу и остаётся, пока его не отменить здесь.</p>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<p class="muted" style="font-size:12px; text-align:center">LPRA сервер · версия {{.ServerVersion}}</p>
|
||||
</div>
|
||||
<script>
|
||||
// Copy an install command: the clipboard API on https, the old way otherwise.
|
||||
(function () {
|
||||
var CHECK = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>';
|
||||
function copy(text) {
|
||||
if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(text);
|
||||
var ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
var ok = document.execCommand('copy');
|
||||
ta.remove();
|
||||
return ok ? Promise.resolve() : Promise.reject(new Error('нет доступа к буферу'));
|
||||
}
|
||||
document.querySelectorAll('.copybox .copy').forEach(function (b) {
|
||||
var icon = b.innerHTML;
|
||||
b.onclick = function () {
|
||||
copy(document.getElementById(b.dataset.copy).textContent.trim()).then(function () {
|
||||
b.innerHTML = CHECK; b.classList.add('done'); b.title = 'Скопировано';
|
||||
setTimeout(function () { b.innerHTML = icon; b.classList.remove('done'); b.title = 'Скопировать'; }, 1500);
|
||||
}, function (e) { alert('Не скопировалось: ' + e.message); });
|
||||
};
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<script src="{{asset "users.js"}}"></script>
|
||||
<script src="{{asset "account.js"}}"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -3,8 +3,8 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Настройка — LPRA</title>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
<title>Настройка - LPRA</title>
|
||||
<link rel="stylesheet" href="{{asset "app.css"}}">
|
||||
</head>
|
||||
<body>
|
||||
<div class="center">
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
<!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="{{asset "app.css"}}">
|
||||
<script src="{{asset "pin.js"}}"></script>
|
||||
<script src="{{asset "scroll.js"}}" defer></script>
|
||||
<style>
|
||||
.meters { display:grid; grid-template-columns:repeat(auto-fit, minmax(240px, 1fr)); gap:20px; margin-bottom:20px; }
|
||||
.meters .card { margin:0; }
|
||||
.meter .head { display:flex; align-items:baseline; justify-content:space-between; }
|
||||
.meter .name { color:var(--muted); font-size:13px; }
|
||||
.meter .val { font-size:26px; font-weight:600; font-variant-numeric:tabular-nums; }
|
||||
.meter .sub { color:var(--muted); font-size:13px; margin-top:8px; min-height:20px; font-variant-numeric:tabular-nums; }
|
||||
.bar { height:8px; border-radius:4px; background:#0c0f13; border:1px solid var(--border); overflow:hidden; margin-top:10px; }
|
||||
.bar i { display:block; height:100%; width:100%; background:var(--accent); transform-origin:left; transform:scaleX(0); transition:transform .4s; }
|
||||
.bar i.warn { background:#e6a23c; }
|
||||
.bar i.crit { background:var(--danger); }
|
||||
.speed { display:flex; gap:28px; flex-wrap:wrap; margin-top:16px; }
|
||||
.speed b { display:block; font-size:20px; font-variant-numeric:tabular-nums; }
|
||||
.speed span { color:var(--muted); font-size:13px; }
|
||||
.live { display:flex; align-items:center; gap:28px; flex-wrap:wrap; margin-top:20px; }
|
||||
.gauge { position:relative; width:240px; flex:none; }
|
||||
.gauge svg { display:block; width:100%; }
|
||||
.gauge path { fill:none; stroke-width:14; stroke-linecap:round; }
|
||||
.gauge .track { stroke:#0c0f13; }
|
||||
.gauge .fill { stroke:var(--accent); stroke-dasharray:251.4; stroke-dashoffset:251.4; transition:stroke-dashoffset .25s linear, stroke .3s; }
|
||||
.gauge .num { position:absolute; left:0; right:0; bottom:26px; text-align:center; font-size:38px; font-weight:600; line-height:1; font-variant-numeric:tabular-nums; }
|
||||
.gauge .unit { position:absolute; left:0; right:0; bottom:4px; text-align:center; color:var(--muted); font-size:13px; }
|
||||
.live .side { flex:1; min-width:220px; }
|
||||
.live .phase { font-size:15px; margin-bottom:8px; }
|
||||
.live .phase i { font-style:normal; color:var(--accent); margin-right:6px; }
|
||||
.live.up .phase i { color:var(--ok); }
|
||||
.live.up .gauge .fill { stroke:var(--ok); }
|
||||
.spark { display:block; width:100%; height:64px; }
|
||||
.spark polyline { fill:none; stroke:var(--accent); stroke-width:2; vector-effect:non-scaling-stroke; stroke-linejoin:round; }
|
||||
.spark polygon { fill:var(--accent); opacity:.15; }
|
||||
.live.up .spark polyline { stroke:var(--ok); }
|
||||
.live.up .spark polygon { fill:var(--ok); }
|
||||
label.check { display:inline-flex; align-items:center; gap:8px; margin:0; color:var(--text); font-size:14px; cursor:pointer; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand"><a class="home" href="/" title="К списку машин">LPRA</a> · Статус - {{.Agent.Name}} <span class="muted">({{.Agent.Hostname}})</span></div>
|
||||
<nav>
|
||||
<a href="/">← Машины</a>
|
||||
<a href="/agents/{{.Agent.ID}}/terminal">Терминал</a>
|
||||
<a href="/agents/{{.Agent.ID}}/files">Файлы</a>
|
||||
<a href="/agents/{{.Agent.ID}}/forwards">Порты</a>
|
||||
<a href="/agents/{{.Agent.ID}}/edit">Изменить</a>
|
||||
</nav>
|
||||
</header>
|
||||
<div class="wrap">
|
||||
<h1>Статус</h1>
|
||||
<div class="err" id="err" hidden></div>
|
||||
|
||||
<div class="meters">
|
||||
<div class="card meter">
|
||||
<div class="head"><span class="name">Процессор</span><span class="val" id="cpu-val">-</span></div>
|
||||
<div class="bar"><i id="cpu-bar"></i></div>
|
||||
<div class="sub" id="cpu-sub"></div>
|
||||
</div>
|
||||
<div class="card meter">
|
||||
<div class="head"><span class="name">Оперативная память</span><span class="val" id="mem-val">-</span></div>
|
||||
<div class="bar"><i id="mem-bar"></i></div>
|
||||
<div class="sub" id="mem-sub"></div>
|
||||
</div>
|
||||
<div class="card meter">
|
||||
<div class="head"><span class="name">Диск (/)</span><span class="val" id="disk-val">-</span></div>
|
||||
<div class="bar"><i id="disk-bar"></i></div>
|
||||
<div class="sub" id="disk-sub"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Скорость соединения</h2>
|
||||
<p class="muted">Клиент замеряет свой канал до хоста панели - тот же, по которому идут терминал, файлы и http-проброс.
|
||||
С галочкой «До браузера» данные идут дальше, через панель до этого браузера и обратно: так виден весь путь клиент - панель - браузер.
|
||||
Замер занимает около 15 секунд, не дольше 40, и на это время нагружает канал.</p>
|
||||
<div class="row">
|
||||
<button id="speed-btn" type="button">Замерить скорость</button>
|
||||
<label class="check" title="Данные пойдут не только между клиентом и хостом панели, а через панель до этого браузера и обратно">
|
||||
<input type="checkbox" id="speed-relay"> До браузера
|
||||
</label>
|
||||
<span class="muted" id="speed-note"></span>
|
||||
</div>
|
||||
<div class="live" id="live" hidden>
|
||||
<div class="gauge">
|
||||
<svg viewBox="0 0 200 110">
|
||||
<path class="track" d="M20 100 A80 80 0 0 1 180 100"/>
|
||||
<path class="fill" id="lv-arc" d="M20 100 A80 80 0 0 1 180 100"/>
|
||||
</svg>
|
||||
<div class="num" id="lv-num">-</div>
|
||||
<div class="unit" id="lv-unit"></div>
|
||||
</div>
|
||||
<div class="side">
|
||||
<div class="phase"><i id="lv-ico"></i><span id="lv-phase"></span></div>
|
||||
<svg class="spark" viewBox="0 0 100 100" preserveAspectRatio="none">
|
||||
<polygon id="lv-area" points=""/>
|
||||
<polyline id="lv-line" points=""/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="speed" id="speed" hidden>
|
||||
<div><b id="sp-ping"></b><span>задержка</span></div>
|
||||
<div><b id="sp-down"></b><span id="sp-down-lbl"></span></div>
|
||||
<div><b id="sp-up"></b><span id="sp-up-lbl"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
var base = '/api/panel/agents/' + encodeURIComponent({{.Agent.ID}});
|
||||
var $ = function (id) { return document.getElementById(id); };
|
||||
var errBox = $('err');
|
||||
|
||||
function size(n) {
|
||||
var u = ['Б', 'КБ', 'МБ', 'ГБ', 'ТБ'], i = 0;
|
||||
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
|
||||
return n.toFixed(n < 10 && i > 0 ? 1 : 0) + ' ' + u[i];
|
||||
}
|
||||
function rateParts(bps) {
|
||||
var u = ['бит/с', 'Кбит/с', 'Мбит/с', 'Гбит/с'], i = 0;
|
||||
while (bps >= 1000 && i < u.length - 1) { bps /= 1000; i++; }
|
||||
return [bps.toFixed(bps < 100 && i > 0 ? 1 : 0), u[i]];
|
||||
}
|
||||
function rate(bps) { return rateParts(bps).join(' '); }
|
||||
function meter(name, pct, sub) {
|
||||
pct = Math.max(0, Math.min(100, pct));
|
||||
$(name + '-val').textContent = Math.round(pct) + '%';
|
||||
var bar = $(name + '-bar');
|
||||
bar.style.transform = 'scaleX(' + pct / 100 + ')';
|
||||
bar.className = pct >= 90 ? 'crit' : pct >= 75 ? 'warn' : '';
|
||||
$(name + '-sub').textContent = sub;
|
||||
}
|
||||
function showErr(msg) {
|
||||
errBox.textContent = msg || '';
|
||||
errBox.hidden = !msg;
|
||||
}
|
||||
function cores(n) {
|
||||
var d = n % 10, h = n % 100;
|
||||
return n + (d === 1 && h !== 11 ? ' ядро' : d >= 2 && d <= 4 && (h < 12 || h > 14) ? ' ядра' : ' ядер');
|
||||
}
|
||||
|
||||
// One request at a time, and none while the tab is hidden: the client is
|
||||
// sampled only for a page somebody is looking at.
|
||||
var timer = null, busy = false;
|
||||
function schedule(ms) {
|
||||
clearTimeout(timer);
|
||||
if (!document.hidden) timer = setTimeout(load, ms);
|
||||
}
|
||||
function load() {
|
||||
if (busy || document.hidden) return;
|
||||
busy = true;
|
||||
var next = 3000;
|
||||
fetch(base + '/stat', {credentials: 'same-origin'})
|
||||
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
|
||||
.then(function (res) {
|
||||
if (!res.ok) { showErr(res.error || 'ошибка'); next = 10000; return; }
|
||||
showErr('');
|
||||
var d = res.data;
|
||||
meter('cpu', d.cpu, cores(d.ncpu));
|
||||
meter('mem', d.mem_total ? 100 * d.mem_used / d.mem_total : 0,
|
||||
'занято ' + size(d.mem_used) + ' из ' + size(d.mem_total) + ' · свободно ' + size(d.mem_total - d.mem_used));
|
||||
meter('disk', d.disk_total ? 100 * (d.disk_total - d.disk_free) / d.disk_total : 0,
|
||||
'свободно ' + size(d.disk_free) + ' из ' + size(d.disk_total));
|
||||
})
|
||||
.catch(function (e) { showErr('Нет связи с панелью: ' + e.message); next = 10000; })
|
||||
.then(function () { busy = false; schedule(next); });
|
||||
}
|
||||
document.addEventListener('visibilitychange', function () {
|
||||
if (document.hidden) clearTimeout(timer); else load();
|
||||
});
|
||||
load();
|
||||
|
||||
var btn = $('speed-btn'), note = $('speed-note');
|
||||
function json(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }
|
||||
function post(path) { return fetch(base + path, {method: 'POST', credentials: 'same-origin'}).then(json); }
|
||||
|
||||
// The browser's round trip to the panel: the first request may pay for a
|
||||
// new connection, so keep the best.
|
||||
function panelPing() {
|
||||
var best = Infinity, left = 4;
|
||||
function one() {
|
||||
var t = performance.now();
|
||||
return fetch('/api/panel/ping', {credentials: 'same-origin', cache: 'no-store'})
|
||||
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.arrayBuffer(); })
|
||||
.then(function () {
|
||||
best = Math.min(best, performance.now() - t);
|
||||
return --left > 0 ? one() : best;
|
||||
});
|
||||
}
|
||||
return one();
|
||||
}
|
||||
// Keeps the client's download fed until stopped or the panel says the
|
||||
// client has read enough; the pieces grow while they go through quickly.
|
||||
function feed(stop) {
|
||||
var block = new Uint8Array(65536), n = 4;
|
||||
crypto.getRandomValues(block);
|
||||
function one() {
|
||||
var parts = [], t = performance.now();
|
||||
for (var i = 0; i < n; i++) parts.push(block);
|
||||
return fetch(base + '/speed/down', {method: 'POST', body: new Blob(parts), credentials: 'same-origin', signal: stop.signal})
|
||||
.then(function (r) {
|
||||
if (r.status !== 204) return;
|
||||
if (performance.now() - t < 500 && n < 256) n *= 2;
|
||||
return one();
|
||||
});
|
||||
}
|
||||
one().catch(function () {});
|
||||
}
|
||||
// Reads what the client sends and times it from the first byte. It stops by
|
||||
// itself a little after the client's window, so bytes still queued on a slow
|
||||
// link don't hold the result back. st.n is the count so far, for the live
|
||||
// reading.
|
||||
function drain(st, onFirst) {
|
||||
var ac = new AbortController(), last = 0;
|
||||
st.stop = function () { ac.abort(); };
|
||||
function done() { return st.start && last > st.start ? st.n * 8000 / (last - st.start) : 0; }
|
||||
return fetch(base + '/speed/up', {credentials: 'same-origin', cache: 'no-store', signal: ac.signal}).then(function (r) {
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
var rd = r.body.getReader();
|
||||
function pump() {
|
||||
return rd.read().then(function (c) {
|
||||
if (c.done) return done();
|
||||
last = performance.now();
|
||||
if (!st.start) { st.start = last; setTimeout(st.stop, {{.RelayUpMs}}); onFirst(); }
|
||||
else st.n += c.value.length;
|
||||
return pump();
|
||||
});
|
||||
}
|
||||
return pump();
|
||||
}).catch(function (e) { if (st.start) return done(); throw e; });
|
||||
}
|
||||
// The test through the browser: the client's download is what this page
|
||||
// uploads, and its upload is timed here, where it arrives.
|
||||
function relayTest(st) {
|
||||
return panelPing().then(function (ping) {
|
||||
return post('/speed/relay').then(function (res) {
|
||||
if (!res.ok) return res;
|
||||
var stop = new AbortController();
|
||||
var up = drain(st, function () { stop.abort(); });
|
||||
up.catch(function () {});
|
||||
feed(stop);
|
||||
return post('/speed?relay=1').then(function (res) {
|
||||
stop.abort();
|
||||
// No upload under way by now: none is coming.
|
||||
if (!res.ok || !st.start) { st.stop(); }
|
||||
if (!res.ok) return res;
|
||||
return up.catch(function () { return 0; }).then(function (bps) {
|
||||
res.data.ping_ms += ping;
|
||||
if (bps) res.data.up_bps = bps;
|
||||
return res;
|
||||
});
|
||||
}, function (e) { stop.abort(); st.stop(); throw e; });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// The live reading: the rate over the last second, from the byte counts the
|
||||
// panel keeps for the running test - or, for the client's upload in a test
|
||||
// through the browser, from what has arrived here.
|
||||
var ARC = 251.4, lv = null;
|
||||
function liveStart(relay, st) {
|
||||
lv = {relay: relay, st: st, phase: '', pts: [], hist: [], busy: false};
|
||||
$('live').hidden = false;
|
||||
liveShow('ping', 0);
|
||||
lv.timer = setInterval(liveTick, 200);
|
||||
}
|
||||
function liveStop() {
|
||||
if (!lv) return;
|
||||
clearInterval(lv.timer);
|
||||
lv = null;
|
||||
$('live').hidden = true;
|
||||
}
|
||||
function liveTick() {
|
||||
var cur = lv;
|
||||
if (cur.relay && cur.st.start) { livePoint(cur, 'up', cur.st.n, performance.now()); return; }
|
||||
if (cur.busy) return;
|
||||
cur.busy = true;
|
||||
fetch(base + '/speed/progress', {credentials: 'same-origin', cache: 'no-store'})
|
||||
.then(json)
|
||||
.then(function (d) {
|
||||
if (cur !== lv || !d.phase || (cur.relay && cur.st.start)) return;
|
||||
livePoint(cur, d.phase, d.phase === 'up' ? d.up : d.down, d.ms);
|
||||
})
|
||||
.catch(function () {})
|
||||
.then(function () { cur.busy = false; });
|
||||
}
|
||||
function livePoint(cur, phase, bytes, ms) {
|
||||
if (phase !== cur.phase) { cur.phase = phase; cur.pts = []; cur.hist = []; }
|
||||
if (phase === 'ping') { liveShow(phase, 0); return; }
|
||||
var pts = cur.pts;
|
||||
pts.push([ms, bytes]);
|
||||
while (pts.length > 2 && ms - pts[0][0] > 1000) pts.shift();
|
||||
if (pts.length < 2 || ms <= pts[0][0]) return;
|
||||
var bps = (bytes - pts[0][1]) * 8000 / (ms - pts[0][0]);
|
||||
cur.hist.push(bps);
|
||||
liveShow(phase, bps, cur);
|
||||
}
|
||||
function liveShow(phase, bps, cur) {
|
||||
var relay = lv && lv.relay, p = rateParts(bps);
|
||||
$('live').className = 'live' + (phase === 'up' ? ' up' : '');
|
||||
$('lv-ico').textContent = phase === 'up' ? '↑' : phase === 'down' ? '↓' : '';
|
||||
$('lv-phase').textContent = phase === 'ping' ? 'Задержка…'
|
||||
: phase === 'up' ? (relay ? 'клиент → браузер' : 'клиент → хост')
|
||||
: (relay ? 'браузер → клиент' : 'хост → клиент');
|
||||
$('lv-num').textContent = phase === 'ping' ? '…' : p[0];
|
||||
$('lv-unit').textContent = phase === 'ping' ? '' : p[1];
|
||||
// A log scale up to 1 Гбит/с, so a slow link moves the needle too.
|
||||
var f = Math.min(1, Math.log(1 + bps / 1e6) / Math.log(1001));
|
||||
$('lv-arc').style.strokeDashoffset = ARC * (1 - f);
|
||||
var h = cur ? cur.hist : [], max = 1.25 * Math.max.apply(null, h.concat(1)), line = '';
|
||||
for (var i = 0; i < h.length; i++) {
|
||||
line += (h.length > 1 ? 100 * i / (h.length - 1) : 0).toFixed(1) + ',' + (98 - 94 * h[i] / max).toFixed(1) + ' ';
|
||||
}
|
||||
$('lv-line').setAttribute('points', line);
|
||||
$('lv-area').setAttribute('points', h.length > 1 ? '0,100 ' + line + '100,100' : '');
|
||||
}
|
||||
|
||||
btn.onclick = function () {
|
||||
var relay = $('speed-relay').checked, st = {n: 0, start: 0, stop: function () {}};
|
||||
btn.disabled = true;
|
||||
note.textContent = 'Идёт замер…';
|
||||
$('speed').hidden = true;
|
||||
liveStart(relay, st);
|
||||
(relay ? relayTest(st) : post('/speed'))
|
||||
.then(function (res) {
|
||||
if (!res.ok) { note.textContent = res.error || 'ошибка'; return; }
|
||||
var d = res.data;
|
||||
$('sp-ping').textContent = d.ping_ms ? d.ping_ms.toFixed(d.ping_ms < 10 ? 1 : 0) + ' мс' : '-';
|
||||
$('sp-down').textContent = rate(d.down_bps);
|
||||
$('sp-up').textContent = d.up_bps ? rate(d.up_bps) : '-';
|
||||
$('sp-down-lbl').textContent = relay ? 'браузер → клиент' : 'хост → клиент';
|
||||
$('sp-up-lbl').textContent = relay ? 'клиент → браузер' : 'клиент → хост';
|
||||
$('speed').hidden = false;
|
||||
note.textContent = d.partial ? 'Замер остановлен по времени - показано то, что успело набраться' : '';
|
||||
})
|
||||
.catch(function (e) { note.textContent = 'Не удалось: ' + e.message; })
|
||||
.then(function () { liveStop(); btn.disabled = false; });
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -3,147 +3,421 @@
|
||||
<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">
|
||||
<link rel="stylesheet" href="/static/xterm.css">
|
||||
<title>Терминал · {{.Agent.Name}} - LPRA</title>
|
||||
<link rel="stylesheet" href="{{asset "app.css"}}">
|
||||
<script src="{{asset "pin.js"}}"></script>
|
||||
<script src="{{asset "scroll.js"}}" defer></script>
|
||||
<link rel="stylesheet" href="{{asset "xterm.css"}}">
|
||||
<style>
|
||||
#status.st-ok { color:#37c871; }
|
||||
#status.st-err { color:#ff5c5c; }
|
||||
#status { display:inline-block; vertical-align:bottom; min-width:0; max-width:42vw; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
/* SSH: the badge on a pane, and the dialog to connect. */
|
||||
.badge-ssh { flex:none; font-size:10px; font-weight:600; letter-spacing:.04em; padding:1px 5px; border-radius:4px;
|
||||
background:#12202f; color:var(--accent); border:1px solid #1f3a57; }
|
||||
dialog.sshdlg { max-width:460px; }
|
||||
.sshdlg .dlg-h .ico { color:var(--accent); display:inline-flex; }
|
||||
.sshdlg .dlg-b { display:grid; gap:12px; }
|
||||
.sshdlg .f { display:grid; gap:5px; margin:0; }
|
||||
.sshdlg .f > span { font-size:12px; color:var(--muted); }
|
||||
.sshdlg .frow { display:flex; gap:10px; align-items:end; }
|
||||
.sshdlg .frow .grow { flex:1; min-width:0; }
|
||||
.sshdlg .frow .port { width:92px; flex:none; }
|
||||
.sshdlg input, .sshdlg select, .sshdlg textarea { width:100%; box-sizing:border-box; }
|
||||
.sshdlg textarea { font:12px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; resize:vertical; min-height:90px;
|
||||
background:var(--bg, #0c0f13); color:var(--text); border:1px solid var(--border); border-radius:8px; padding:8px 10px; }
|
||||
.sshdlg textarea:focus { border-color:var(--accent); outline:none; }
|
||||
.sshdlg .auth { display:grid; gap:8px; animation:auth-in .18s ease; }
|
||||
.sshdlg .auth[hidden] { display:none; }
|
||||
@keyframes auth-in { from { opacity:0; transform:translateY(-4px); } }
|
||||
.sshdlg p.auth { margin:0; font-size:13px; }
|
||||
.sshdlg .keyname { font-size:12px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; align-self:center; }
|
||||
.sshdlg .seg { display:flex; padding:3px; gap:3px; background:#0c0f13; border:1px solid var(--border); border-radius:9px; }
|
||||
.sshdlg .seg button { flex:1; padding:6px 8px; font-size:13px; background:transparent; color:var(--muted); border:0; border-radius:6px;
|
||||
transition:background .18s, color .18s; }
|
||||
.sshdlg .seg button:hover { color:var(--text); }
|
||||
.sshdlg .seg button.on { background:#12202f; color:var(--text); box-shadow:inset 0 0 0 1px #1f3a57; }
|
||||
.sshdlg .sshmsg { margin:0; min-height:18px; font-size:13px; color:var(--danger); }
|
||||
.sshdlg .row.end { display:flex; justify-content:flex-end; gap:8px; }
|
||||
@media (max-width:520px) { .sshdlg .seg button { font-size:12px; padding:6px 4px; } }
|
||||
/* The page is the terminal: bar, tabs, then the terminals to the bottom. */
|
||||
body { height:100vh; display:flex; flex-direction:column; overflow:hidden; }
|
||||
header.topbar { flex:none; }
|
||||
/* Tabs: folded away while there is one, sliding in with the second. */
|
||||
.tabs { flex:none; display:flex; align-items:flex-end; gap:4px; padding:0 16px; max-height:0; opacity:0; overflow:hidden;
|
||||
transition:max-height .28s ease, opacity .22s ease, padding .28s ease; }
|
||||
body.multi .tabs, body.multi-hint .tabs { max-height:48px; opacity:1; padding-top:10px; }
|
||||
.tablist { position:relative; display:flex; gap:4px; min-width:0; overflow-x:auto; scrollbar-width:none; }
|
||||
.tablist::-webkit-scrollbar { display:none; }
|
||||
.tab { display:flex; align-items:center; gap:8px; flex:none; min-width:0; height:34px; padding:0 6px 0 12px; box-sizing:border-box;
|
||||
border:1px solid var(--border); border-bottom:0; border-radius:9px 9px 0 0; background:#11151b; color:var(--muted);
|
||||
font-size:13px; user-select:none; cursor:default; overflow:hidden; white-space:nowrap;
|
||||
transition:background .18s, color .18s, width .22s ease, max-width .22s ease, opacity .2s, padding .22s ease; }
|
||||
.tab:hover { color:var(--text); background:#151a21; }
|
||||
.tab.on { background:var(--panel); color:var(--text); }
|
||||
.tab.enter, .tab.leave { max-width:0; opacity:0; padding:0; }
|
||||
.tab.dragging { opacity:.3; }
|
||||
.tab-title { flex:1; overflow:hidden; text-overflow:ellipsis; min-width:0; }
|
||||
.tab-edit { width:auto; min-width:60px; max-width:150px; height:24px; padding:2px 6px; font-size:13px; }
|
||||
.tab-x, .pane-x { flex:none; width:20px; height:20px; padding:0; display:inline-flex; align-items:center; justify-content:center;
|
||||
background:transparent; border:0; border-radius:5px; color:var(--muted); opacity:.6; transition:opacity .15s, background .15s, color .15s; }
|
||||
.tab-x:hover, .pane-x:hover { opacity:1; background:#3a1d1d; color:var(--danger); }
|
||||
.tab-add { flex:none; width:34px; height:34px; padding:0; display:inline-flex; align-items:center; justify-content:center;
|
||||
border:1px solid var(--border); border-bottom:0; border-radius:9px 9px 0 0; background:transparent; color:var(--muted);
|
||||
font-size:18px; line-height:1; transition:background .18s, color .18s; }
|
||||
.tab-add:hover { background:#151a21; color:var(--text); }
|
||||
.tabmark { position:absolute; top:6px; bottom:0; width:3px; margin-left:-1px; border-radius:2px; background:var(--accent);
|
||||
pointer-events:none; transition:left .12s ease; }
|
||||
.tabmark[hidden] { display:none; }
|
||||
.dot { width:7px; height:7px; border-radius:50%; background:#555d6a; flex:none; transition:background .3s; }
|
||||
.dot.ok { background:var(--ok); } .dot.err { background:var(--danger); padding:0; border:0; margin:0; } .dot.wait { background:#e6a23c; }
|
||||
/* The terminals' area. */
|
||||
.area { position:relative; flex:1; min-height:0; margin:12px 16px 16px; border:1px solid var(--border); border-radius:10px; background:#0c0f13; overflow:hidden; }
|
||||
body.multi .area { margin-top:0; border-top-left-radius:0; }
|
||||
.float-add { position:absolute; top:8px; right:10px; z-index:6; width:28px; height:28px; padding:0; display:inline-flex;
|
||||
align-items:center; justify-content:center; font-size:17px; line-height:1; color:var(--text);
|
||||
background:transparent; border:1px solid var(--border); opacity:.28; transition:opacity .25s ease, background .25s ease, top .25s ease, right .25s ease; }
|
||||
/* One tab split in panes: below the panes' title bars, clear of their close buttons. */
|
||||
body.split1 .float-add { top:42px; right:16px; }
|
||||
.float-add:hover, .float-add:focus-visible { opacity:1; background:var(--panel); }
|
||||
body.multi .float-add { opacity:0; pointer-events:none; }
|
||||
.tabview { position:absolute; inset:8px; opacity:0; visibility:hidden; transform:translateY(4px) scale(.995);
|
||||
transition:opacity .22s ease, transform .22s ease, visibility 0s .22s; }
|
||||
.tabview.on { opacity:1; visibility:visible; transform:none; transition:opacity .22s ease, transform .22s ease, visibility 0s; }
|
||||
.pane { position:absolute; display:flex; flex-direction:column; border-radius:7px; overflow:hidden;
|
||||
transition:left .26s ease, top .26s ease, width .26s ease, height .26s ease, border-color .2s; }
|
||||
.tabview.instant .pane, .pane.instant { transition:none; }
|
||||
.pane-head { display:none; }
|
||||
.tabview.split .pane { border:1px solid var(--border); }
|
||||
.tabview.split .pane.active { border-color:rgba(79,157,255,.7); }
|
||||
.tabview.split .pane-head { display:flex; align-items:center; gap:7px; flex:none; height:26px; padding:0 4px 0 9px;
|
||||
background:#11151b; border-bottom:1px solid var(--border); font-size:12px; color:var(--muted); cursor:grab; user-select:none; }
|
||||
.tabview.split .pane.active .pane-head { color:var(--text); }
|
||||
.pane-title { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.pane-body { flex:1; min-height:0; padding:4px 0 0 4px; }
|
||||
/* A lone terminal's scrollbar sits in the margin by the frame, right of
|
||||
the corner "+", not over the text. */
|
||||
body:not(.touch) .tabview:not(.split) .pane { overflow:visible; }
|
||||
body:not(.touch) .tabview:not(.split) .xterm .xterm-viewport { right:-7px; }
|
||||
.pane.dragging { opacity:.45; }
|
||||
.gutter { position:absolute; z-index:3; }
|
||||
.gutter.c { cursor:col-resize; } .gutter.r { cursor:row-resize; }
|
||||
.gutter::after { content:""; position:absolute; inset:0; border-radius:3px; background:var(--accent); opacity:0; transition:opacity .15s; }
|
||||
.gutter:hover::after, .gutter.drag::after { opacity:.55; }
|
||||
.hint { position:absolute; z-index:5; display:flex; align-items:center; justify-content:center; text-align:center; padding:8px;
|
||||
border:2px solid var(--accent); border-radius:9px; background:rgba(79,157,255,.14); color:var(--text); font-size:13px;
|
||||
pointer-events:none; transition:left .14s ease, top .14s ease, width .14s ease, height .14s ease; }
|
||||
.hint.bad { border-color:var(--danger); background:rgba(255,92,92,.12); }
|
||||
.hint[hidden] { display:none; }
|
||||
.drag-ghost { position:fixed; left:0; top:0; z-index:60; pointer-events:none; padding:7px 12px; border:1px solid var(--accent);
|
||||
border-radius:8px; background:var(--panel); color:var(--text); font-size:13px; white-space:nowrap;
|
||||
box-shadow:0 12px 30px rgba(0,0,0,.55); transition:transform .04s linear; }
|
||||
.drag-ghost[data-hint]:not([data-hint=""])::after { content:attr(data-hint); display:block; margin-top:3px; font-size:11px; color:var(--muted); }
|
||||
body.dnd, body.dnd * { cursor:grabbing !important; }
|
||||
.empty { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:12px; color:var(--muted); }
|
||||
.empty[hidden] { display:none; }
|
||||
/* The "+" menu: a terminal here, or on another machine. */
|
||||
.addmenu { position:fixed; z-index:50; min-width:270px; max-width:340px; max-height:70vh; overflow:auto; padding:6px;
|
||||
background:var(--panel); border:1px solid var(--border); border-radius:10px; box-shadow:0 14px 36px rgba(0,0,0,.55);
|
||||
animation:menu-in .14s ease; }
|
||||
.addmenu[hidden] { display:none; }
|
||||
@keyframes menu-in { from { opacity:0; transform:translateY(-4px); } }
|
||||
.addmenu .item { display:flex; align-items:center; gap:10px; width:100%; padding:8px 10px; border-radius:7px; text-align:left;
|
||||
background:transparent; color:var(--text); font-weight:400; }
|
||||
.addmenu .item:hover:not(:disabled) { background:#12202f; }
|
||||
.addmenu .item:disabled { opacity:.45; cursor:not-allowed; }
|
||||
.addmenu .item b { display:block; font-weight:500; font-size:14px; }
|
||||
.addmenu .item small { display:block; color:var(--muted); font-size:12px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:240px; }
|
||||
.addmenu .ico { width:22px; text-align:center; color:var(--accent); font-size:16px; flex:none; }
|
||||
.addmenu .chev { margin-left:auto; color:var(--muted); }
|
||||
.addmenu .mlist { border-top:1px solid var(--border); margin-top:4px; padding-top:4px; }
|
||||
/* Touch screens: the page is as tall as what the on-screen keyboard leaves,
|
||||
with a row of extra keys at the bottom, the way Termux does it. Tabs are
|
||||
smaller, and there is no splitting. */
|
||||
.keys { display:none; }
|
||||
body.touch { position:fixed; left:0; right:0; top:0; height:100%; }
|
||||
body.touch .area { margin:6px; }
|
||||
body.touch.multi .area { margin-top:0; }
|
||||
body.touch .tabs { padding-left:6px; padding-right:6px; }
|
||||
body.touch.multi .tabs { padding-top:6px; max-height:38px; }
|
||||
body.touch .tab { height:28px; font-size:12px; padding:0 2px 0 8px; gap:6px; }
|
||||
body.touch .tab-add { width:30px; height:28px; }
|
||||
body.touch .tabview { inset:4px; }
|
||||
body.touch .keys {
|
||||
display:grid; grid-template-columns:repeat(7, 1fr); gap:4px; flex:none;
|
||||
padding:4px 4px calc(4px + env(safe-area-inset-bottom)); background:var(--panel); border-top:1px solid var(--border);
|
||||
}
|
||||
.keys button {
|
||||
height:38px; padding:0; border:1px solid var(--border); border-radius:6px;
|
||||
background:#0c0f13; color:var(--text); font:500 13px ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
touch-action:manipulation; user-select:none; -webkit-user-select:none; -webkit-tap-highlight-color:transparent;
|
||||
}
|
||||
.keys button:active { background:var(--border); }
|
||||
.keys button.on { background:var(--accent); border-color:var(--accent); color:#fff; }
|
||||
.keys button.lock { background:var(--accent); border-color:#fff; color:#fff; }
|
||||
/* Copy and paste on touch screens: a long press selects, this menu acts. */
|
||||
.selmenu { position:fixed; left:50%; transform:translateX(-50%); z-index:20; display:flex; gap:7px; padding:5px;
|
||||
background:var(--panel); border:1px solid var(--border); border-radius:8px; box-shadow:0 6px 24px rgba(0,0,0,.5); }
|
||||
.selmenu[hidden] { display:none; }
|
||||
.selmenu button { width:42px; height:42px; padding:0; display:inline-flex; align-items:center; justify-content:center;
|
||||
touch-action:manipulation; -webkit-tap-highlight-color:transparent; }
|
||||
body.touch .area { -webkit-user-select:none; user-select:none; -webkit-touch-callout:none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand">LPRA · Терминал — {{.Agent.Name}} <span class="muted">({{.Agent.Hostname}})</span></div>
|
||||
<div class="brand"><a class="home" href="/" title="К списку машин">LPRA</a> · Терминал - {{.Agent.Name}} <span class="muted">({{.Agent.TerminalUser}}@{{.Agent.Hostname}})</span></div>
|
||||
<nav>
|
||||
<a href="/">← Машины</a>
|
||||
<a href="/agents/{{.Agent.ID}}/files">Файлы</a>
|
||||
<a href="/agents/{{.Agent.ID}}/forwards">Порты</a>
|
||||
<span id="status" class="muted" style="margin-left:28px">подключение…</span>
|
||||
<a href="/agents/{{.Agent.ID}}/status">Статус</a>
|
||||
<a href="/agents/{{.Agent.ID}}/edit">Изменить</a>
|
||||
<span id="status" class="muted" style="margin-left:28px"></span>
|
||||
</nav>
|
||||
</header>
|
||||
<div class="wrap" style="max-width:none">
|
||||
<div id="terminal"></div>
|
||||
<div class="tabs" id="tabs">
|
||||
<div class="tablist" id="tablist"><div class="tabmark" id="tabmark" hidden></div></div>
|
||||
<button type="button" class="tab-add" id="tabadd" title="Новый терминал" aria-label="Новый терминал">+</button>
|
||||
</div>
|
||||
<div class="area" id="area">
|
||||
<button type="button" class="float-add" id="floatadd" title="Новый терминал" aria-label="Новый терминал">+</button>
|
||||
<div class="empty" id="empty" hidden>Нет открытых терминалов<button type="button" id="emptyadd">Новый терминал</button></div>
|
||||
<div class="hint" id="hint" hidden></div>
|
||||
</div>
|
||||
<div class="addmenu" id="addmenu" hidden></div>
|
||||
<div class="selmenu" id="selmenu" hidden>
|
||||
<button type="button" data-a="copy" title="Копировать" aria-label="Копировать">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"/></svg>
|
||||
</button>
|
||||
<button type="button" class="btn ghost" data-a="paste" title="Вставить" aria-label="Вставить">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="8" y="2" width="8" height="4" rx="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/></svg>
|
||||
</button>
|
||||
<button type="button" class="btn ghost" data-a="all" title="Выделить всё" aria-label="Выделить всё">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 3a2 2 0 0 0-2 2M19 3a2 2 0 0 1 2 2M21 19a2 2 0 0 1-2 2M5 21a2 2 0 0 1-2-2M9 3h1M9 21h1M14 3h1M14 21h1M3 9v1M21 9v1M3 14v1M21 14v1"/><rect x="7" y="7" width="10" height="10" rx="1"/></svg>
|
||||
</button>
|
||||
<button type="button" class="btn ghost close" data-a="close" title="Закрыть" aria-label="Закрыть">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="keys" id="keys">
|
||||
<button type="button" data-k="esc">ESC</button>
|
||||
<button type="button" data-k="/">/</button>
|
||||
<button type="button" data-k="-">-</button>
|
||||
<button type="button" data-k="home">HOME</button>
|
||||
<button type="button" data-k="up">↑</button>
|
||||
<button type="button" data-k="end">END</button>
|
||||
<button type="button" data-k="pgup">PGUP</button>
|
||||
<button type="button" data-k="tab">TAB</button>
|
||||
<button type="button" data-k="ctrl" data-mod="ctrl">CTRL</button>
|
||||
<button type="button" data-k="alt" data-mod="alt">ALT</button>
|
||||
<button type="button" data-k="left">←</button>
|
||||
<button type="button" data-k="down">↓</button>
|
||||
<button type="button" data-k="right">→</button>
|
||||
<button type="button" data-k="pgdn">PGDN</button>
|
||||
</div>
|
||||
|
||||
<script src="/static/xterm.js"></script>
|
||||
<script src="/static/addon-fit.js"></script>
|
||||
<script src="{{asset "xterm.js"}}"></script>
|
||||
<script src="{{asset "addon-fit.js"}}"></script>
|
||||
<script>
|
||||
(function () {
|
||||
const agentID = {{.Agent.ID}};
|
||||
const statusEl = document.getElementById('status');
|
||||
function setStatus(t, cls) { statusEl.textContent = t; statusEl.className = cls || ''; }
|
||||
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 14,
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
||||
theme: { background: '#0c0f13' }
|
||||
});
|
||||
const fit = new FitAddon.FitAddon();
|
||||
term.loadAddon(fit);
|
||||
term.open(document.getElementById('terminal'));
|
||||
fit.fit();
|
||||
|
||||
function bytesToB64(bytes) {
|
||||
let bin = '';
|
||||
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
|
||||
return btoa(bin);
|
||||
window.TERM_PAGE = { agent: { id: {{.Agent.ID}}, name: {{.Agent.Name}}, host: {{.Agent.Hostname}}, user: {{.Agent.TerminalUser}} } };
|
||||
// Touch helpers, for the terminal being typed in: the extra keys with their
|
||||
// CTRL / ALT, and selecting and copying with a long press.
|
||||
window.TermKeys = (function () {
|
||||
const touch = window.matchMedia('(pointer: coarse)').matches;
|
||||
const cur = () => window.TermPane && window.TermPane.active();
|
||||
// CTRL and ALT: a tap arms the key for the next key press, a long press
|
||||
// keeps it on until tapped again.
|
||||
const mods = { ctrl: 0, alt: 0 }; // 0 off, 1 next key, 2 locked
|
||||
const modBtn = {};
|
||||
document.querySelectorAll('#keys [data-mod]').forEach(function (b) { modBtn[b.dataset.mod] = b; });
|
||||
function showMods() {
|
||||
for (const m in mods) {
|
||||
modBtn[m].classList.toggle('on', mods[m] === 1);
|
||||
modBtn[m].classList.toggle('lock', mods[m] === 2);
|
||||
}
|
||||
}
|
||||
function b64ToBytes(b64) {
|
||||
const bin = atob(b64);
|
||||
const u = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) u[i] = bin.charCodeAt(i);
|
||||
return u;
|
||||
function useMods() {
|
||||
const used = { ctrl: !!mods.ctrl, alt: !!mods.alt };
|
||||
for (const m in mods) if (mods[m] === 1) mods[m] = 0;
|
||||
showMods();
|
||||
return used;
|
||||
}
|
||||
|
||||
const base = '/api/panel/term/';
|
||||
let sid = null;
|
||||
let closed = false;
|
||||
let connected = false;
|
||||
|
||||
function post(path, body) {
|
||||
return fetch(path, {
|
||||
method: 'POST',
|
||||
headers: body ? { 'Content-Type': 'application/json' } : {},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
keepalive: true
|
||||
function ctrlChar(c) {
|
||||
if (c === ' ' || c === '@') return '\x00';
|
||||
if (c === '?') return '\x7f';
|
||||
const code = c.toUpperCase().charCodeAt(0);
|
||||
return code >= 64 && code <= 95 ? String.fromCharCode(code & 31) : c;
|
||||
}
|
||||
function withMods(data) {
|
||||
if (!mods.ctrl && !mods.alt) return data;
|
||||
const m = useMods();
|
||||
if (m.ctrl && data.length === 1) data = ctrlChar(data);
|
||||
return m.alt ? '\x1b' + data : data;
|
||||
}
|
||||
// xterm's modifier parameter: 1 + shift(1) + alt(2) + ctrl(4).
|
||||
const CSI = { up: 'A', down: 'B', right: 'C', left: 'D', home: 'H', end: 'F' };
|
||||
const TILDE = { pgup: 5, pgdn: 6 };
|
||||
function keySeq(k, term) {
|
||||
const m = useMods(), p = 1 + (m.alt ? 2 : 0) + (m.ctrl ? 4 : 0);
|
||||
if (CSI[k]) {
|
||||
if (p > 1) return '\x1b[1;' + p + CSI[k];
|
||||
return (term.modes.applicationCursorKeysMode ? '\x1bO' : '\x1b[') + CSI[k];
|
||||
}
|
||||
if (TILDE[k]) return '\x1b[' + TILDE[k] + (p > 1 ? ';' + p : '') + '~';
|
||||
let s = k === 'esc' ? '\x1b' : k === 'tab' ? '\t' : k;
|
||||
if (m.ctrl && s.length === 1) s = ctrlChar(s);
|
||||
return m.alt ? '\x1b' + s : s;
|
||||
}
|
||||
let press = null;
|
||||
document.querySelectorAll('#keys button').forEach(function (b) {
|
||||
// Keep the focus (and the on-screen keyboard) on the terminal.
|
||||
b.addEventListener('pointerdown', function (e) {
|
||||
e.preventDefault();
|
||||
const mod = b.dataset.mod;
|
||||
if (mod) press = { mod: mod, timer: setTimeout(function () { mods[mod] = 2; press = null; showMods(); }, 450) };
|
||||
});
|
||||
}
|
||||
|
||||
async function open() {
|
||||
try {
|
||||
const r = await post(base + encodeURIComponent(agentID) + '/open');
|
||||
if (!r.ok) {
|
||||
const body = await r.text().catch(() => '');
|
||||
setStatus('ошибка открытия (' + r.status + ')', 'st-err');
|
||||
term.write('\r\n\x1b[31m[не удалось открыть сессию: HTTP ' + r.status + ']\x1b[0m\r\n');
|
||||
if (body) term.write(body.slice(0, 300).replace(/\n/g, '\r\n') + '\r\n');
|
||||
if (r.status === 404) term.write('\x1b[33mМашина не найдена на сервере — возможно, устаревший бинарник сервера без маршрута /open.\x1b[0m\r\n');
|
||||
b.addEventListener('pointerup', function (e) {
|
||||
e.preventDefault();
|
||||
const mod = b.dataset.mod;
|
||||
if (mod) {
|
||||
if (press && press.mod === mod) {
|
||||
clearTimeout(press.timer);
|
||||
press = null;
|
||||
mods[mod] = mods[mod] ? 0 : 1;
|
||||
showMods();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const j = await r.json();
|
||||
sid = j.session_id;
|
||||
setStatus('ожидание агента…');
|
||||
sendResize();
|
||||
pollLoop();
|
||||
} catch (e) {
|
||||
setStatus('ошибка сети', 'st-err');
|
||||
term.write('\r\n\x1b[31m[ошибка сети: ' + (e && e.message ? e.message : e) + ']\x1b[0m\r\n');
|
||||
}
|
||||
}
|
||||
|
||||
async function pollLoop() {
|
||||
while (!closed && sid) {
|
||||
let r;
|
||||
try {
|
||||
r = await fetch(base + sid + '/out', { cache: 'no-store' });
|
||||
} catch (e) {
|
||||
// transient network hiccup; brief pause then retry
|
||||
await new Promise(res => setTimeout(res, 1000));
|
||||
continue;
|
||||
}
|
||||
if (!r.ok) { finish('[соединение закрыто]'); return; }
|
||||
const j = await r.json();
|
||||
if (j.closed) { finish('[сессия завершена]'); return; }
|
||||
if (j.frames) {
|
||||
for (const f of j.frames) {
|
||||
if (f.exit) { finish('[сессия завершена]'); return; }
|
||||
if (f.data) {
|
||||
if (!connected) { connected = true; setStatus('подключено', 'st-ok'); }
|
||||
term.write(b64ToBytes(f.data));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function finish(msg) {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
setStatus('соединение закрыто', 'muted');
|
||||
term.write('\r\n\x1b[33m' + msg + '\x1b[0m\r\n');
|
||||
}
|
||||
|
||||
term.onData(function (data) {
|
||||
if (closed || !sid) return;
|
||||
const bytes = new TextEncoder().encode(data);
|
||||
post(base + sid + '/in', { t: 'i', d: bytesToB64(bytes) });
|
||||
const s = cur();
|
||||
if (s) s.send(keySeq(b.dataset.k, s.term));
|
||||
});
|
||||
b.addEventListener('pointercancel', function () { if (press) { clearTimeout(press.timer); press = null; } });
|
||||
b.addEventListener('contextmenu', function (e) { e.preventDefault(); });
|
||||
});
|
||||
|
||||
function sendResize() {
|
||||
if (closed || !sid) return;
|
||||
post(base + sid + '/in', { t: 'r', cols: term.cols, rows: term.rows });
|
||||
// --- Selecting and copying on touch screens ---
|
||||
// xterm.js has no touch selection of its own. A long press selects the word
|
||||
// under the finger and dragging on from there stretches the selection; a
|
||||
// menu then copies it or pastes. A plain tap or swipe still types and scrolls.
|
||||
const menu = document.getElementById('selmenu');
|
||||
let selecting = false;
|
||||
function showMenu() {
|
||||
const s = cur();
|
||||
const keys = document.getElementById('keys').getBoundingClientRect();
|
||||
menu.style.top = '';
|
||||
menu.style.bottom = (window.innerHeight - keys.top + 14) + 'px';
|
||||
menu.querySelector('[data-a="copy"]').disabled = !(s && s.term.hasSelection());
|
||||
menu.hidden = false;
|
||||
}
|
||||
window.addEventListener('resize', function () { fit.fit(); sendResize(); });
|
||||
term.onResize(sendResize);
|
||||
function selectionChanged(s) {
|
||||
if (!touch || s !== cur()) return;
|
||||
if (s.term.hasSelection()) showMenu();
|
||||
else if (!selecting) menu.hidden = true;
|
||||
}
|
||||
if (touch) {
|
||||
const box = document.getElementById('area');
|
||||
let timer = null, start = null, anchor = null;
|
||||
function cellAt(term, el, x, y) {
|
||||
const r = el.querySelector('.xterm-screen').getBoundingClientRect();
|
||||
const col = Math.max(0, Math.min(term.cols - 1, Math.floor((x - r.left) / (r.width / term.cols))));
|
||||
const row = Math.max(0, Math.min(term.rows - 1, Math.floor((y - r.top) / (r.height / term.rows))));
|
||||
return { col: col, row: row + term.buffer.active.viewportY };
|
||||
}
|
||||
function selectWord(term, c) {
|
||||
const line = term.buffer.active.getLine(c.row);
|
||||
const text = line ? line.translateToString(false) : '';
|
||||
const word = ch => ch && !/\s/.test(ch);
|
||||
let a = c.col, b = c.col;
|
||||
if (word(text[c.col])) {
|
||||
while (a > 0 && word(text[a - 1])) a--;
|
||||
while (b < text.length - 1 && word(text[b + 1])) b++;
|
||||
}
|
||||
term.select(a, c.row, b - a + 1);
|
||||
anchor = { from: c.row * term.cols + a, to: c.row * term.cols + b };
|
||||
}
|
||||
function stretch(term, c) {
|
||||
const p = c.row * term.cols + c.col;
|
||||
const from = Math.min(anchor.from, p), to = Math.max(anchor.to, p);
|
||||
term.select(from % term.cols, Math.floor(from / term.cols), to - from + 1);
|
||||
}
|
||||
function hideMenu() { menu.hidden = true; const s = cur(); if (s) s.term.clearSelection(); anchor = null; }
|
||||
box.addEventListener('touchstart', function (e) {
|
||||
if (e.touches.length !== 1 || !e.target.closest('.pane-body')) { clearTimeout(timer); return; }
|
||||
const t = e.touches[0];
|
||||
start = { x: t.clientX, y: t.clientY };
|
||||
if (!menu.hidden) hideMenu();
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(function () {
|
||||
const s = cur();
|
||||
if (!s) return;
|
||||
selecting = true;
|
||||
selectWord(s.term, cellAt(s.term, s.el, start.x, start.y));
|
||||
showMenu();
|
||||
if (navigator.vibrate) navigator.vibrate(15);
|
||||
}, 450);
|
||||
}, { passive: true, capture: true });
|
||||
box.addEventListener('touchmove', function (e) {
|
||||
const t = e.touches[0], s = cur();
|
||||
if (selecting && s) {
|
||||
e.preventDefault(); // no scrolling while the selection stretches
|
||||
e.stopPropagation();
|
||||
stretch(s.term, cellAt(s.term, s.el, t.clientX, t.clientY));
|
||||
} else if (start && Math.hypot(t.clientX - start.x, t.clientY - start.y) > 8) {
|
||||
clearTimeout(timer); // a swipe: let it scroll
|
||||
}
|
||||
}, { passive: false, capture: true });
|
||||
box.addEventListener('touchend', function (e) {
|
||||
clearTimeout(timer);
|
||||
if (!selecting) return;
|
||||
selecting = false;
|
||||
e.preventDefault(); // no tap: the keyboard stays as it was
|
||||
e.stopPropagation();
|
||||
showMenu();
|
||||
}, { passive: false, capture: true });
|
||||
box.addEventListener('contextmenu', function (e) { e.preventDefault(); });
|
||||
|
||||
window.addEventListener('pagehide', function () {
|
||||
if (sid) navigator.sendBeacon(base + sid + '/close');
|
||||
});
|
||||
|
||||
term.focus();
|
||||
open();
|
||||
const statusEl = document.getElementById('status');
|
||||
function flash(t, cls) {
|
||||
statusEl.textContent = t; statusEl.className = cls;
|
||||
setTimeout(function () { const s = cur(); if (s) s.setState(s.state, s.text); }, 1500);
|
||||
}
|
||||
function copyText(text) {
|
||||
if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(text);
|
||||
// Plain http: the old way, through a field that is there for a moment.
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
const ok = document.execCommand('copy');
|
||||
ta.remove();
|
||||
return ok ? Promise.resolve() : Promise.reject(new Error('нет доступа к буферу'));
|
||||
}
|
||||
menu.addEventListener('pointerdown', function (e) { e.preventDefault(); });
|
||||
menu.addEventListener('click', function (e) {
|
||||
const b = e.target.closest('button'), s = cur();
|
||||
if (!b || !s) return;
|
||||
const a = b.dataset.a;
|
||||
if (a === 'copy') {
|
||||
const text = s.term.getSelection();
|
||||
hideMenu();
|
||||
copyText(text).then(function () { flash('скопировано', 'st-ok'); }, function (err) { flash('не скопировалось: ' + err.message, 'st-err'); });
|
||||
} else if (a === 'paste') {
|
||||
hideMenu();
|
||||
if (!navigator.clipboard || !navigator.clipboard.readText) { flash('вставка: браузер не даёт буфер', 'st-err'); return; }
|
||||
navigator.clipboard.readText().then(function (t) { if (t) s.term.paste(t); }, function () { flash('вставка: нет доступа к буферу', 'st-err'); });
|
||||
} else if (a === 'all') {
|
||||
s.term.selectAll();
|
||||
showMenu();
|
||||
} else {
|
||||
hideMenu();
|
||||
}
|
||||
});
|
||||
}
|
||||
return { withMods: withMods, selectionChanged: selectionChanged };
|
||||
})();
|
||||
</script>
|
||||
<script src="{{asset "terminal.js"}}"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,203 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/coder/websocket/wsjson"
|
||||
|
||||
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||
)
|
||||
|
||||
// An agent's WebSocket carries what its long polls would: control messages
|
||||
// pushed the moment they are queued, and the input and output of its
|
||||
// terminal and http-forward sessions, without a request per keystroke. Tasks,
|
||||
// file transfers and the speed test stay on plain requests.
|
||||
|
||||
// wsPing keeps the connection alive through proxies that drop quiet ones
|
||||
// (Cloudflare after 100 s).
|
||||
const wsPing = 20 * time.Second
|
||||
|
||||
// wsAgents are the agents connected by WebSocket now, for the panel to show.
|
||||
type wsAgents struct {
|
||||
mu sync.Mutex
|
||||
m map[string]*websocket.Conn
|
||||
}
|
||||
|
||||
// set records the agent's connection, closing one it had before.
|
||||
func (c *wsAgents) set(id string, conn *websocket.Conn) {
|
||||
c.mu.Lock()
|
||||
if c.m == nil {
|
||||
c.m = map[string]*websocket.Conn{}
|
||||
}
|
||||
old := c.m[id]
|
||||
c.m[id] = conn
|
||||
c.mu.Unlock()
|
||||
if old != nil {
|
||||
old.Close(websocket.StatusGoingAway, "replaced")
|
||||
}
|
||||
}
|
||||
|
||||
func (c *wsAgents) drop(id string, conn *websocket.Conn) {
|
||||
c.mu.Lock()
|
||||
if c.m[id] == conn {
|
||||
delete(c.m, id)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// On reports whether the agent is on its WebSocket now.
|
||||
func (c *wsAgents) On(id string) bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.m[id] != nil
|
||||
}
|
||||
|
||||
// transportOf is the agent's setting as stored, "auto" when unset.
|
||||
func (s *Server) transportOf(agentID string) string {
|
||||
if a, err := s.db.GetAgent(agentID); err == nil && a != nil && a.Transport != "" {
|
||||
return a.Transport
|
||||
}
|
||||
return proto.TransportAuto
|
||||
}
|
||||
|
||||
// GET /api/agent/ws - the agent's WebSocket. Refused (409) when the agent is
|
||||
// set to long polling.
|
||||
func (s *Server) handleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
a := s.authenticateAgent(r)
|
||||
if a == nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if a.Transport == proto.TransportLP {
|
||||
w.Header().Set(proto.TransportHeader, proto.TransportLP)
|
||||
http.Error(w, "long polling only", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
if v := r.Header.Get("X-Agent-Version"); v != "" {
|
||||
_ = s.db.TouchAgentInfo(a.ID, v, firstNonEmpty(r.Header.Get("X-Agent-User"), a.RunUser))
|
||||
}
|
||||
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{CompressionMode: websocket.CompressionDisabled})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
s.viaDirectA.Store(a.ID, s.viaDirect(r))
|
||||
conn.SetReadLimit(4 << 20)
|
||||
ctx, cancel := context.WithCancel(r.Context())
|
||||
defer cancel()
|
||||
s.wsAgents.set(a.ID, conn)
|
||||
defer s.wsAgents.drop(a.ID, conn)
|
||||
defer conn.CloseNow()
|
||||
|
||||
send := func(m proto.WSMsg) error {
|
||||
wctx, wcancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer wcancel()
|
||||
return wsjson.Write(wctx, conn, m)
|
||||
}
|
||||
cfg := func() proto.WSMsg {
|
||||
return proto.WSMsg{T: "cfg", Tunnel: s.tunnelAdvert(), Transport: s.transportOf(a.ID), Fallback: s.db.DirectAddress(), Main: s.db.HostAddress()}
|
||||
}
|
||||
if send(cfg()) != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Control: what a poll would get, as soon as it is queued. An idle round
|
||||
// sends the settings, which doubles as a sign of life.
|
||||
go func() {
|
||||
defer cancel()
|
||||
for ctx.Err() == nil {
|
||||
msg := s.hub.WaitControl(ctx.Done(), a.ID, pollHold)
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
_ = s.db.TouchAgent(a.ID)
|
||||
out := proto.WSMsg{T: "ctrl", Ctrl: &msg}
|
||||
if msg.Type == proto.CtrlIdle {
|
||||
out = cfg()
|
||||
}
|
||||
if send(out) != nil {
|
||||
return
|
||||
}
|
||||
if out.Transport == proto.TransportLP {
|
||||
conn.Close(websocket.StatusNormalClosure, "long polling only")
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
t := time.NewTicker(wsPing)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
pctx, pcancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
err := conn.Ping(pctx)
|
||||
pcancel()
|
||||
if err != nil {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
subs := map[string]bool{}
|
||||
for {
|
||||
var m proto.WSMsg
|
||||
if err := wsjson.Read(ctx, conn, &m); err != nil {
|
||||
return
|
||||
}
|
||||
switch m.T {
|
||||
case "sub":
|
||||
if subs[m.SID] {
|
||||
continue
|
||||
}
|
||||
sess := s.hub.Session(m.SID)
|
||||
if sess == nil || sess.AgentID != a.ID {
|
||||
_ = send(proto.WSMsg{T: "gone", SID: m.SID})
|
||||
continue
|
||||
}
|
||||
subs[m.SID] = true
|
||||
go s.pumpSession(ctx, sess, send)
|
||||
case "out":
|
||||
sess := s.hub.Session(m.SID)
|
||||
if sess == nil || sess.AgentID != a.ID || m.Out == nil {
|
||||
_ = send(proto.WSMsg{T: "gone", SID: m.SID})
|
||||
continue
|
||||
}
|
||||
if !sess.PushOut(*m.Out) {
|
||||
_ = send(proto.WSMsg{T: "gone", SID: m.SID})
|
||||
continue
|
||||
}
|
||||
if m.Out.Exit {
|
||||
sess.close()
|
||||
time.AfterFunc(pollHold, func() { s.hub.CloseSession(sess.ID) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pumpSession sends a session's input down the WebSocket as it comes. Input
|
||||
// taken just as the connection ends goes back to the session, for the agent's
|
||||
// long poll to collect.
|
||||
func (s *Server) pumpSession(ctx context.Context, sess *Session, send func(proto.WSMsg) error) {
|
||||
for ctx.Err() == nil {
|
||||
frames, alive := sess.PollIn(pollHold)
|
||||
if !alive {
|
||||
_ = send(proto.WSMsg{T: "gone", SID: sess.ID})
|
||||
return
|
||||
}
|
||||
if len(frames) == 0 {
|
||||
continue
|
||||
}
|
||||
if ctx.Err() != nil || send(proto.WSMsg{T: "in", SID: sess.ID, Frames: frames}) != nil {
|
||||
sess.Unpoll(frames)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// Package tunnel defines the framed multiplexing protocol used for DIRECT port
|
||||
// forwarding over the dedicated host port (default 8446). Unlike the HTTP
|
||||
// forwarding over the dedicated host port (DefaultPort). Unlike the HTTP
|
||||
// long-poll forward, this is a raw TCP stream the agent dials outbound; it
|
||||
// carries many concurrent forwarded connections (streams) for one agent and
|
||||
// has far lower latency than long-polling.
|
||||
|
||||
+14
@@ -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