41 Commits
Author SHA1 Message Date
redtea 0fa3de757c feat: edit SQLite databases in the file manager - the agent opens them through SQLite: values by a click, rows added and deleted, an SQL tab; changes go to the audit log 2026-10-15 16:50:56 +03:00
redtea 0279c1d887 fix: corner plus also slides 6 px left over a split tab 2026-10-15 16:42:27 +03:00
redtea 0ccac0bc82 fix: scrollbars darker 2026-10-15 16:38:23 +03:00
redtea e5a45b872a fix: scrollbars more transparent 2026-10-15 16:35:38 +03:00
redtea 9e684605ef fix: terminal scrollbar centred between the plus and the frame 2026-10-15 16:34:39 +03:00
redtea 13897c7478 fix: terminal scrollbar 2 px further left again 2026-10-15 16:34:01 +03:00
redtea 4d2cfe8244 fix: terminal scrollbar 2 px further left 2026-10-15 16:33:06 +03:00
redtea 25ffa60d09 feat: Mac-like scrollbars - thin, no track, shown while scrolling or at the edge and fading out, none when there is nothing to scroll; a lone terminal's bar sits by the frame, right of the plus 2026-10-15 16:32:51 +03:00
redtea baee90b564 fix: terminal corner plus moves left and down only on Windows, clear of its wide scrollbar 2026-10-15 11:47:34 +03:00
redtea 79828e3f8a feat: installer fetched from the direct address sets it as the agent's own with the main one as fallback (and downloads from it); agents fall back to whichever address is not theirs; (direct) beside the connection status 2026-10-15 11:34:58 +03:00
redtea 021da93229 feat: user roles - admins and users limited to the machines given to them, user editor with machines, password sign-in and its lock; direct address without Cloudflare as the agents' fallback and in the installer; version 0.1.2 2026-10-15 10:56:19 +03:00
redtea 36dc12b69d feat: terminal copy and paste by keys off a Mac - Ctrl+C copies a selection (interrupts without one), Ctrl+Shift+C copies and stays from the browser, Ctrl+V and Ctrl+Shift+V paste 2026-10-15 10:45:58 +03:00
redtea 1297ab36d0 fix: transfer dialog keeps its body and shows a spinner and progress while sending; LPRA in the header leads to the machines; no settings link on the settings page; corner plus clear of the scrollbar 2026-10-15 10:44:10 +03:00
redtea fd382d6313 feat: PIN lock - logout left of zero, erase and logout in a muted red 2026-10-15 10:41:22 +03:00
redtea fc488717d3 feat: login - passkey button under the password form, the sign-in button full width 2026-10-15 10:39:00 +03:00
redtea dbb46173a3 feat: dialogs open and close smoothly, the backdrop fading with them 2026-10-15 10:38:02 +03:00
redtea b47e824692 fix: quick reconnect after sleep or a network outage - the tunnel notices a dead connection, retries every 5 s at most, the WebSocket returns as soon as the server answers; version 0.1.1 2026-10-15 00:07:32 +03:00
redtea 5eca9ca215 fix: agent update runs as root when it can (sudo without a password otherwise), checks the download, restarts reliably and reports its result; one-colour file icons; zip and delete on one line on phones; PIN buttons alike; no key fingerprint in the SSH status; corner plus clears split panes 2026-10-14 23:54:24 +03:00
redtea 467bd3cb4f docs: README - SSH terminal, sending to other machines, PIN, passkeys, WebSocket transport, Cloudflare and nginx notes 2026-10-14 21:30:39 +03:00
redtea 18d6684c55 feat: send or move files and folders to another machine or an SSH host from the move dialog, streamed as tar with progress, speed and cancel; same conflict rules as a local move 2026-10-14 21:29:25 +03:00
redtea 1a38c2ac18 feat: terminal tabs a little wider by default 2026-10-14 21:20:37 +03:00
redtea 5a4dc596d5 feat: SSH terminal - the agent connects by SSH to another host (password, key file or its own keys) and the shell opens in a tab with the connection's progress shown; host keys trusted on first use, a changed one refused 2026-10-14 21:20:18 +03:00
redtea 5a204ae9bb feat: a device can skip the PIN for 5, 15, 30 or 60 minutes after it was entered (off by default, this browser only) 2026-10-14 21:13:50 +03:00
redtea 119b524825 feat: optional 4-digit PIN asked in every new tab (not on reload), on-screen keypad for phones; set, change or remove it in the account; five misses sign out 2026-10-14 21:12:09 +03:00
redtea 29353e5e6a feat: install code of 3 to 24 letters and digits with a button to make a new one; guesses are limited per address 2026-10-14 21:07:36 +03:00
redtea 47f1b2e55b feat: the browser terminal on a WebSocket too, long polls when it does not connect or drops 2026-10-14 21:06:32 +03:00
redtea 73bb720ec6 feat: agents connect by WebSocket, falling back to long polling and trying again each minute; auto, ws or lp per machine (0.1.0) 2026-10-14 21:04:13 +03:00
redtea 6dd1120a41 feat: the password is turned off by hand in the account, with a passkey in place; password change; tabs sized by their titles 2026-10-14 20:46:35 +03:00
redtea 67222e2e05 feat: terminal tabs - several machines at once, up to four terminals split in a tab by drag and drop, resizable, renamable; the top bar keeps its status in sight 2026-10-14 20:42:41 +03:00
redtea e7b5d28470 feat: sign in with passkeys, no password once an account has one; an account button and dialog in place of the sign-out button 2026-10-14 20:33:31 +03:00
redtea d2d6fe0100 fix: the copy menu shows as soon as text is selected, a little higher above the extra keys 2026-10-14 20:27:21 +03:00
redtea e983fc179a feat: move or copy dialog with folder suggestions and a rule for what is already there 2026-10-14 20:25:59 +03:00
redtea 89d5bfd3ab feat: copy buttons for the install commands, icons on the file bar buttons; the path title back on the left, the upload figures sliding, dialogs focused without a ring 2026-10-14 20:12:03 +03:00
redtea 55c37a0097 fix: the spinner by the path takes room only while busy, opening and closing smoothly 2026-10-14 20:03:03 +03:00
redtea cefdae267b fix: the spinner by the path takes only its own width 2026-10-14 19:53:42 +03:00
redtea 44e7137d86 fix: the spinner by the path keeps its place, so the path field does not jump 2026-10-14 19:51:34 +03:00
redtea 10ffa393ca fix: a closed terminal hangs its shell up instead of killing it, so the history is saved; idle shells end with their session, and on agent stop 2026-10-14 19:50:50 +03:00
redtea 114c6fc530 fix: static files linked with their content hash, so a release is never shadowed by a cached copy (Cloudflare kept the old CSS) 2026-10-14 19:40:31 +03:00
redtea 0aa066fd01 fix: a smaller frame for the properties button in the path field 2026-10-14 19:37:37 +03:00
redtea 21436d5c73 feat: the folder properties button sits inside the path field 2026-10-14 19:37:25 +03:00
redtea 65359f5497 feat: file and folder properties - mode, owner and group, applied to a folder's contents too (0.0.8) 2026-10-14 19:31:25 +03:00
55 changed files with 7911 additions and 482 deletions

No files matched your search

+117 -40
View File
@@ -1,74 +1,151 @@
# LPRA - LongPolling Remote Access # LPRA - LongPolling Remote Access
Лёгкий self-hosted сервис для доступа к Linux-машинам **без белого IP** (за NAT). Лёгкий self-hosted сервис для доступа к Linux-машинам **без белого IP** (за NAT).
Агент на клиенте сам исходяще подключается к серверу по HTTP long-polling; админ Агент на клиенте сам исходяще подключается к серверу - по WebSocket, а где он не проходит,
в веб-панели видит список машин и открывает по каждой **терминал в браузере** (по умолчанию от root, пользователя можно сменить). по HTTP long-polling. В веб-панели видно список машин, и по каждой есть терминал в браузере,
файловый менеджер, проброс портов и статус.
``` ```
браузер ──WS──> [nginx :8445 TLS] ──> [Go server 127.0.0.1:8091] <──long-poll── агент (root, за NAT) браузер ──HTTPS/WS──> [Cloudflare] ──> [nginx TLS] ──> [Go server 127.0.0.1:8091] <──WS / long-poll── агент (за NAT)
│ │ ▲
SQLite 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/server`) - панель и точка встречи для агентов. Ставится за nginx (TLS).
- **Агент** (`cmd/agent`) - Linux-бинарь, systemd-сервис от root, PTY по запросу. - **Агент** (`cmd/agent`) - бинарь для Linux (amd64/arm64), systemd-сервис от root.
- **install.sh** - интерактивный установщик, отдаётся сервером как статика. - **install.sh** - установщик агента, отдаётся сервером.
## Сборка ## Сборка
```sh ```sh
./build.sh ./build.sh
``` ```
Получаются `bin/lpra-server` и `dist/agent-linux-{amd64,arm64}`. Получаются `bin/lpra-server*` и `dist/agent-linux-{amd64,arm64}`.
Версия задаётся в одном месте - `VERSION.txt`, она вшивается в сервер и агент.
## Запуск сервера ## Запуск сервера
```sh ```sh
./bin/lpra-server -addr 127.0.0.1:8091 -dist ./dist -db ./lpra.db ./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`: Пример юнита - `deploy/lpra-server.service`.
адрес хоста (по умолчанию `https://localhost:8445`), enroll-секрет и готовая
команда установки.
## Установка агента на клиент Первый запуск: откройте `/setup` - мастер создаст администратора. Дальше в `/settings`:
Одной командой (под root): адрес хоста, enroll-секрет, короткий код установки и готовая команда.
```sh
curl -fsSL https://localhost:8445/install.sh | sudo HOST=https://localhost:8445 ENROLL_SECRET=<секрет> sh ### 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 ```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` идёт через адрес панели (long-poll), Правила создаются на странице машины «Порты». Режим `http` идёт через адрес панели,
режимы `tcp`/`udp` - через прямой туннель: агент держит исходящее соединение к порту туннеля хоста `tcp`/`udp` - через прямой туннель: агент держит исходящее соединение к порту туннеля хоста,
(флаг `-tunnel` / `LPRA_TUNNEL_ADDR`). по нему мультиплексируются все соединения.
Порт туннеля и хост, к которому для него подключаются агенты (например, IP вместо домена панели), Порт туннеля и хост для агентов меняются в `/settings` → «Прямой туннель»; агенты забирают их
меняются в `/settings` → «Прямой туннель». Агенты забирают адрес с панели сами - при подключении и сами, переустановка не нужна. Локальные `TUNNEL_ADDR` / `TUNNEL_PORT` в окружении агента имеют
при каждом опросе, переустановка не нужна. Локальные `TUNNEL_ADDR` / `TUNNEL_PORT` в окружении агента приоритет (`TUNNEL_ADDR=off` отключает туннель).
имеют приоритет над настройкой панели (`TUNNEL_ADDR=off` отключает туннель).
Если на хосте активен ufw и сервер запущен от root, панель сама открывает порт хоста правила Если на хосте активен ufw и сервер запущен от root, панель сама открывает порты правил и порт
(tcp для `http`/`tcp`, udp для `udp`) и порт туннеля для прямых режимов, а при удалении правила - закрывает. туннеля, а при удалении правила закрывает. При `ProtectSystem=strict` в юните нужно
При `ProtectSystem=strict` в systemd-юните ufw нужны права на запись: `ReadWritePaths=/etc/ufw /run`. `ReadWritePaths=/etc/ufw /run`.
## Версия
Версия задаётся в одном месте - `VERSION.txt`; файл вшивается в сервер и агент при сборке.
## Пользователь терминала ## Пользователь терминала
По умолчанию терминал открывается от пользователя, под которым запущен агент (на Linux - root). По умолчанию терминал открывается от пользователя агента (root). В «Изменить машину» можно выбрать
В окне «Изменить машину» можно выбрать другого пользователя машины или создать нового другого пользователя или создать нового (с паролем, по желанию с sudo) - если агент запущен от root.
(имя, пароль, опционально - добавить в sudo). Смена и создание работают, только если агент запущен от root. Файловый менеджер работает от пользователя агента.
Файловый менеджер по-прежнему работает от пользователя агента.
## Безопасность ## Безопасность
- Пароли админов - bcrypt; сессии в HttpOnly/Secure/SameSite куках; CSRF на формах; rate-limit логина. - Пароли админов - bcrypt; сессии в HttpOnly/Secure/SameSite куках; CSRF на формах;
- Агенты: общий enroll-секрет → индивидуальный токен (хранится хешем, сверка constant-time). ограничение попыток входа, PIN и кода установки.
- Открытие терминала пишется в таблицу `audit` (терминал даёт root-доступ), как и смена/создание пользователя. - Ключи доступа (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" /> <img width="1256" height="709" alt="image" src="https://github.com/user-attachments/assets/5f0b30f1-d5e3-4b62-817e-4041641df6e3" />
+1 -1
View File
@@ -1 +1 @@
0.0.7 0.1.2
+7 -1
View File
@@ -11,6 +11,7 @@ import (
"os" "os"
"os/signal" "os/signal"
"strconv" "strconv"
"strings"
"syscall" "syscall"
"github.com/x-grk/longpolling-remote-access/internal/agent" "github.com/x-grk/longpolling-remote-access/internal/agent"
@@ -21,6 +22,7 @@ func main() {
host := envOr("HOST", "https://REDACTED:8445") host := envOr("HOST", "https://REDACTED:8445")
cfg := agent.Config{ cfg := agent.Config{
Host: host, Host: host,
Fallback: strings.TrimRight(os.Getenv("FALLBACK_HOST"), "/"),
EnrollSecret: os.Getenv("ENROLL_SECRET"), EnrollSecret: os.Getenv("ENROLL_SECRET"),
Name: os.Getenv("AGENT_NAME"), Name: os.Getenv("AGENT_NAME"),
StatePath: envOr("STATE_PATH", "/etc/lpagent/state.json"), StatePath: envOr("STATE_PATH", "/etc/lpagent/state.json"),
@@ -38,7 +40,11 @@ func main() {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop() 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) log.Fatalf("agent: %v", err)
} }
} }
+13 -3
View File
@@ -4,14 +4,21 @@
# Put this in /etc/nginx/sites-available/lp_agent and symlink it into # Put this in /etc/nginx/sites-available/lp_agent and symlink it into
# sites-enabled, then: sudo nginx -t && sudo systemctl reload nginx # sites-enabled, then: sudo nginx -t && sudo systemctl reload nginx
# #
# Everything (agent + browser terminal) is plain HTTP long-polling, so NO # Agents connect by WebSocket (from 0.1.0), with HTTP long polling to fall
# WebSocket config is needed. The two settings that matter: # 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_buffering off -> deliver long-poll responses immediately
# - proxy_read_timeout 120s -> longer than the 25s long-poll hold # - 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;" # NOTE: proxy_pass MUST start with "http://". A bare "proxy_pass 127.0.0.1:8091;"
# causes nginx error: [emerg] invalid URL prefix. # 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 { server {
listen 8445 ssl; listen 8445 ssl;
server_name REDACTED; server_name REDACTED;
@@ -33,6 +40,9 @@ server {
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; 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. # Long-poll behaviour: no buffering, generous read timeout.
proxy_buffering off; proxy_buffering off;
+11
View File
@@ -3,6 +3,7 @@ module github.com/x-grk/longpolling-remote-access
go 1.26 go 1.26
require ( require (
github.com/coder/websocket v1.8.12
github.com/creack/pty v1.1.21 github.com/creack/pty v1.1.21
golang.org/x/crypto v0.31.0 golang.org/x/crypto v0.31.0
golang.org/x/sys v0.28.0 golang.org/x/sys v0.28.0
@@ -12,9 +13,19 @@ require (
require ( require (
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // 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/mattn/go-isatty v0.0.16 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/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/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect modernc.org/memory v1.5.0 // indirect
modernc.org/opt v0.1.3 // indirect
modernc.org/strutil v1.1.3 // indirect
modernc.org/token v1.0.1 // indirect
) )
+56 -4
View File
@@ -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 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0=
github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= 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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 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 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 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-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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= 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 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= 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.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 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 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 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= 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 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= 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 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= 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 h1:EsClRIWHGhLTCX44p+Ri/JLD+vFGo0QGjasg2/F9TlI=
modernc.org/sqlite v1.24.0/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= 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=
+169 -15
View File
@@ -18,6 +18,7 @@ import (
"path/filepath" "path/filepath"
"runtime" "runtime"
"sync" "sync"
"sync/atomic"
"time" "time"
lpra "github.com/x-grk/longpolling-remote-access" lpra "github.com/x-grk/longpolling-remote-access"
@@ -30,6 +31,7 @@ var Version = lpra.Version
// Config is the agent's runtime configuration (from env / config.env). // Config is the agent's runtime configuration (from env / config.env).
type Config struct { type Config struct {
Host string // e.g. https://REDACTED:8445 Host string // e.g. https://REDACTED:8445
Fallback string // the direct address, from the installer (FALLBACK_HOST); may be empty
EnrollSecret string EnrollSecret string
Name string // optional friendly name Name string // optional friendly name
StatePath string // where the enrolled id+token are persisted StatePath string // where the enrolled id+token are persisted
@@ -42,6 +44,8 @@ type Config struct {
type state struct { type state struct {
AgentID string `json:"agent_id"` AgentID string `json:"agent_id"`
Token string `json:"token"` Token string `json:"token"`
// Fallback is the direct address as the server told it.
Fallback string `json:"fallback,omitempty"`
} }
// Agent is a running client. // Agent is a running client.
@@ -50,9 +54,13 @@ type Agent struct {
http *http.Client http *http.Client
stream *http.Client stream *http.Client
state state state state
stateMu sync.Mutex
hosts hostState
pollCount int pollCount int
// Direct tunnel currently running (see setTunnelAddr). // 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 tunMu sync.Mutex
tunAddr string tunAddr string
tunCancel context.CancelFunc tunCancel context.CancelFunc
@@ -94,11 +102,20 @@ func (a *Agent) Run(ctx context.Context) error {
return err return err
} }
if a.state.AgentID == "" || a.state.Token == "" { 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) 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). Its address comes // Direct forward tunnel (low-latency TCP mux + UDP). Its address comes
// from the panel; fall back to the configured one when the server sends // from the panel; fall back to the configured one when the server sends
@@ -107,25 +124,142 @@ func (a *Agent) Run(ctx context.Context) error {
a.setTunnelAddr(ctx, a.cfg.TunnelAddr) 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 backoff := time.Second
for ctx.Err() == nil { for ctx.Err() == nil {
if err := a.pollOnce(ctx); err != nil { if a.mode() == proto.TransportLP {
log.Printf("poll: %v (retry in %s)", err, backoff) 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 { select {
case <-time.After(backoff): case <-time.After(backoff):
case <-ctx.Done(): case <-ctx.Done():
return nil return nil
} }
if backoff < 15*time.Second { backoff = min(backoff*2, retryCap)
backoff *= 2
}
continue continue
case proto.TransportAuto:
log.Printf("websocket: %v - long polling, websocket again in a minute", err)
} }
backoff = time.Second backoff = time.Second
a.longPoll(ctx, time.Minute)
} }
return nil 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 { func (a *Agent) enroll(ctx context.Context) error {
host, _ := os.Hostname() host, _ := os.Hostname()
body, _ := json.Marshal(proto.EnrollRequest{ body, _ := json.Marshal(proto.EnrollRequest{
@@ -136,7 +270,7 @@ func (a *Agent) enroll(ctx context.Context) error {
Version: Version, Version: Version,
User: currentUser(), 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("X-Enroll-Secret", a.cfg.EnrollSecret)
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
resp, err := a.http.Do(req) resp, err := a.http.Do(req)
@@ -157,8 +291,10 @@ func (a *Agent) enroll(ctx context.Context) error {
} }
// pollOnce performs one control long-poll and dispatches any instruction. // pollOnce performs one control long-poll and dispatches any instruction.
func (a *Agent) pollOnce(ctx context.Context) error { // The poll itself runs on reqCtx, which may end early; what it starts runs on
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, a.cfg.Host+"/api/agent/poll", nil) // 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) a.authHeaders(req)
// Report the version and our OS user occasionally (not every poll), so the // Report the version and our OS user occasionally (not every poll), so the
// panel can flag outdated agents without extra traffic. // panel can flag outdated agents without extra traffic.
@@ -183,7 +319,15 @@ func (a *Agent) pollOnce(ctx context.Context) error {
return err return err
} }
a.applyTunnelHeader(ctx, resp.Header) a.applyTunnelHeader(ctx, resp.Header)
// Dispatch in the background so we keep polling for more work. 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 { switch msg.Type {
case proto.CtrlOpenTerminal: case proto.CtrlOpenTerminal:
go a.runTerminal(ctx, msg.SessionID, msg.User) go a.runTerminal(ctx, msg.SessionID, msg.User)
@@ -191,8 +335,11 @@ func (a *Agent) pollOnce(ctx context.Context) error {
go a.runForward(ctx, msg.SessionID, msg.Port) go a.runForward(ctx, msg.SessionID, msg.Port)
case proto.CtrlTask: case proto.CtrlTask:
go a.runTask(ctx, msg.TaskID) 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 // fetchConfig asks the server for the settings it hands to agents and reports
@@ -200,20 +347,25 @@ func (a *Agent) pollOnce(ctx context.Context) error {
func (a *Agent) fetchConfig(ctx context.Context) bool { func (a *Agent) fetchConfig(ctx context.Context) bool {
ctx2, cancel := context.WithTimeout(ctx, 10*time.Second) ctx2, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel() defer cancel()
req, _ := http.NewRequestWithContext(ctx2, http.MethodGet, a.cfg.Host+"/api/agent/config", nil) req, _ := http.NewRequestWithContext(ctx2, http.MethodGet, a.host()+"/api/agent/config", nil)
a.authHeaders(req) a.authHeaders(req)
resp, err := a.http.Do(req) resp, err := a.http.Do(req)
if err != nil { if err != nil {
return false return false
} }
defer resp.Body.Close() 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) return a.applyTunnelHeader(ctx, resp.Header)
} }
// applyTunnelHeader follows the tunnel address the server advertises as // applyTunnelHeader follows the tunnel address the server advertises as
// "host:port"; an empty host means the host we already reach the server on. // "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 { func (a *Agent) applyTunnelHeader(ctx context.Context, h http.Header) bool {
v := h.Get("X-Tunnel-Addr") return a.applyTunnelAddr(ctx, h.Get("X-Tunnel-Addr"))
}
func (a *Agent) applyTunnelAddr(ctx context.Context, v string) bool {
if v == "" || a.cfg.TunnelPinned { if v == "" || a.cfg.TunnelPinned {
return false return false
} }
@@ -222,7 +374,7 @@ func (a *Agent) applyTunnelHeader(ctx context.Context, h http.Header) bool {
return false return false
} }
if host == "" { if host == "" {
u, err := url.Parse(a.cfg.Host) u, err := url.Parse(a.host())
if err != nil || u.Hostname() == "" { if err != nil || u.Hostname() == "" {
return false return false
} }
@@ -277,7 +429,9 @@ func (a *Agent) saveState() error {
if err := os.MkdirAll(filepath.Dir(a.cfg.StatePath), 0o700); err != nil { if err := os.MkdirAll(filepath.Dir(a.cfg.StatePath), 0o700); err != nil {
return err return err
} }
a.stateMu.Lock()
b, _ := json.MarshalIndent(a.state, "", " ") b, _ := json.MarshalIndent(a.state, "", " ")
a.stateMu.Unlock()
return os.WriteFile(a.cfg.StatePath, b, 0o600) return os.WriteFile(a.cfg.StatePath, b, 0o600)
} }
+12
View File
@@ -72,6 +72,18 @@ func dispatchFS(env proto.TaskEnvelope) (any, error) {
var a proto.FSZipArgs var a proto.FSZipArgs
_ = json.Unmarshal(env.Args, &a) _ = json.Unmarshal(env.Args, &a)
return fsZip(a.Paths, a.Dest) 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: default:
return nil, fmt.Errorf("неизвестная операция %q", env.Type) return nil, fmt.Errorf("неизвестная операция %q", env.Type)
} }
+166
View File
@@ -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))
}
+163
View File
@@ -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
}
+1 -1
View File
@@ -41,7 +41,7 @@ type errXferGone string
func (e errXferGone) Error() string { return string(e) } func (e errXferGone) Error() string { return string(e) }
func (a *Agent) xferURL(id string, i int) string { func (a *Agent) xferURL(id string, i int) string {
return a.cfg.Host + "/api/agent/fs/xfer/" + id + "/" + strconv.Itoa(i) return a.host() + "/api/agent/fs/xfer/" + id + "/" + strconv.Itoa(i)
} }
// xferRequest makes one request for a piece, trying again over a failing link. // xferRequest makes one request for a piece, trying again over a failing link.
+176
View File
@@ -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
}
+38 -3
View File
@@ -54,6 +54,15 @@ func (a *Agent) bridgeStream(parent context.Context, sid string, rw io.ReadWrite
ctx, cancel := context.WithCancel(parent) ctx, cancel := context.WithCancel(parent)
defer cancel() defer cancel()
defer rw.Close() 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. // Downstream: pull input frames and write them to the stream.
go func() { go func() {
@@ -84,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. // pollIn performs one downstream long-poll and applies the frames.
// Returns false when the session is gone and the loop should stop. // 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 { 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) a.authHeaders(req)
resp, err := a.http.Do(req) resp, err := a.http.Do(req)
if err != nil { if err != nil {
@@ -101,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 { if err := json.NewDecoder(resp.Body).Decode(&batch); err != nil {
return true 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 { switch f.Type {
case proto.FrameStdin: case proto.FrameStdin:
if data, err := b64.DecodeString(f.Data); err == nil { if data, err := b64.DecodeString(f.Data); err == nil {
@@ -120,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. // 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 { 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) 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) a.authHeaders(req)
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
resp, err := a.http.Do(req) resp, err := a.http.Do(req)
+580
View File
@@ -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
}
+1 -1
View File
@@ -119,7 +119,7 @@ func (a *Agent) speedDown(ctx context.Context, path string, window time.Duration
} }
func (a *Agent) speedRequest(ctx context.Context, method, path string, body io.Reader) (*http.Response, error) { func (a *Agent) speedRequest(ctx context.Context, method, path string, body io.Reader) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, method, a.cfg.Host+path, body) req, err := http.NewRequestWithContext(ctx, method, a.host()+path, body)
if err != nil { if err != nil {
return nil, err return nil, err
} }
+512
View File
@@ -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, &notnull, &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
}
+250
View File
@@ -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)
}
+6 -2
View File
@@ -26,6 +26,10 @@ func (a *Agent) runTask(ctx context.Context, taskID string) {
res = a.doFSPut(ctx, env) res = a.doFSPut(ctx, env)
case proto.TaskFSSend: case proto.TaskFSSend:
res = a.doFSSend(ctx, env) res = a.doFSSend(ctx, env)
case proto.TaskFSShipOut:
res = a.doShipOut(ctx, env)
case proto.TaskFSShipIn:
res = a.doShipIn(ctx, env)
default: default:
res = a.execTask(env) res = a.execTask(env)
} }
@@ -34,7 +38,7 @@ func (a *Agent) runTask(ctx context.Context, taskID string) {
func (a *Agent) fetchTask(ctx context.Context, taskID string) (proto.TaskEnvelope, error) { func (a *Agent) fetchTask(ctx context.Context, taskID string) (proto.TaskEnvelope, error) {
var env proto.TaskEnvelope 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) a.authHeaders(req)
resp, err := a.http.Do(req) resp, err := a.http.Do(req)
if err != nil { if err != nil {
@@ -50,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) { func (a *Agent) postResult(ctx context.Context, taskID string, res proto.TaskResult) {
body, _ := json.Marshal(res) 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) a.authHeaders(req)
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
resp, err := a.http.Do(req) resp, err := a.http.Do(req)
+45 -6
View File
@@ -4,6 +4,9 @@ import (
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
"sync"
"syscall"
"time"
"github.com/creack/pty" "github.com/creack/pty"
) )
@@ -12,6 +15,27 @@ import (
type PTY struct { type PTY struct {
cmd *exec.Cmd cmd *exec.Cmd
f *os.File f *os.File
exited chan struct{} // closed once the shell has exited
once sync.Once
}
// 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 // StartPTY launches a login shell attached to a new PTY. With user empty (or
@@ -56,7 +80,10 @@ func StartPTY(user string) (*PTY, error) {
if err != nil { if err != nil {
return nil, err 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 { func isDir(p string) bool {
@@ -78,12 +105,24 @@ func (p *PTY) Resize(cols, rows uint16) {
_ = pty.Setsize(p.f, &pty.Winsize{Rows: rows, Cols: cols}) _ = 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 { func (p *PTY) Close() error {
err := p.f.Close() var err error
if p.cmd.Process != nil { p.once.Do(func() {
_ = p.cmd.Process.Kill() // 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
} }
_ = p.cmd.Wait() openPTYs.Delete(p)
})
return err return err
} }
+8 -6
View File
@@ -26,9 +26,7 @@ func (a *Agent) runTunnel(ctx context.Context, addr string) {
case <-ctx.Done(): case <-ctx.Done():
return return
} }
if backoff < 15*time.Second { backoff = min(backoff*2, retryCap)
backoff *= 2
}
continue continue
} }
backoff = time.Second backoff = time.Second
@@ -96,8 +94,14 @@ func (t *tunClient) write(f tunnel.Frame) error {
return tunnel.WriteFrame(t.conn, f) 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 { func (t *tunClient) readLoop() error {
for { for {
_ = t.conn.SetReadDeadline(time.Now().Add(tunnelSilence))
f, err := tunnel.ReadFrame(t.conn) f, err := tunnel.ReadFrame(t.conn)
if err != nil { if err != nil {
t.shutdown() t.shutdown()
@@ -207,9 +211,7 @@ func (a *Agent) runUDP(ctx context.Context, addr string) {
case <-ctx.Done(): case <-ctx.Done():
return return
} }
if backoff < 15*time.Second { backoff = min(backoff*2, retryCap)
backoff *= 2
}
continue continue
} }
backoff = time.Second backoff = time.Second
+117 -24
View File
@@ -3,31 +3,40 @@ package agent
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"log"
"net/http" "net/http"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"runtime" "runtime"
"strings"
"syscall"
"time" "time"
"github.com/x-grk/longpolling-remote-access/internal/proto" "github.com/x-grk/longpolling-remote-access/internal/proto"
) )
// doUpdate downloads the latest agent binary for this platform, atomically // doUpdate downloads the latest agent binary for this platform, replaces the
// replaces the running executable, and schedules a service restart. It never // running executable and restarts the service. It runs as root when it can:
// uninstalls - only updates. // 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 { func (a *Agent) doUpdate() proto.TaskResult {
exe, err := os.Executable() exe, err := os.Executable()
if err != nil { if err != nil {
return proto.TaskResult{Error: "не удалось определить путь бинарника: " + err.Error()} return proto.TaskResult{Error: "не удалось определить путь бинарника: " + err.Error()}
} }
exe, _ = filepath.EvalSymlinks(exe) 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) req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
resp, err := a.http.Do(req) resp, err := a.stream.Do(req)
if err != nil { if err != nil {
return proto.TaskResult{Error: "скачивание: " + err.Error()} return proto.TaskResult{Error: "скачивание: " + err.Error()}
} }
@@ -36,41 +45,125 @@ func (a *Agent) doUpdate() proto.TaskResult {
return proto.TaskResult{Error: "скачивание: сервер вернул " + resp.Status} return proto.TaskResult{Error: "скачивание: сервер вернул " + resp.Status}
} }
tmp := exe + ".new" // The new binary lands next to the old one, or, when root's hand is
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755) // needed to put it there, in a temporary folder first.
if err != nil { dir := filepath.Dir(exe)
return proto.TaskResult{Error: "создание файла: " + err.Error()} if sudo != "" {
dir = ""
} }
if _, err := io.Copy(f, resp.Body); err != nil { f, err := os.CreateTemp(dir, ".lpagent-new-*")
f.Close() if err != nil {
os.Remove(tmp) 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()} 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; how := "от " + currentUser()
// the live process keeps the old inode until it restarts. if sudo != "" {
if err := os.Rename(tmp, exe); err != nil { // install copies it in place under a new inode, as a rename would.
os.Remove(tmp) if out, err := exec.Command(sudo, "-n", "install", "-m", "0755", tmp, exe).CombinedOutput(); err != nil {
return proto.TaskResult{Error: "замена бинарника: " + err.Error()} 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. // Restart shortly, after the result has been reported.
go func() { go func() {
time.Sleep(1500 * time.Millisecond) 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} return proto.TaskResult{OK: true, Data: data}
} }
// restartSelf restarts the agent so the new binary takes effect. // sudoPath is sudo when it lets this user run commands without a password.
func restartSelf() { 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 { if path, err := exec.LookPath("systemctl"); err == nil {
_ = exec.Command(path, "restart", "lpagent").Start() 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 return
} }
// No systemd (e.g. launchd/manual run): exit so the supervisor restarts us. }
// systemd brings a service that stopped back up (Restart=always).
os.Exit(0) os.Exit(0)
} }
CloseTerminals()
if err := syscall.Exec(exe, os.Args, os.Environ()); err != nil {
log.Printf("update: restart: %v", err)
os.Exit(0)
}
}
+2
View File
@@ -39,6 +39,8 @@ func dispatchTask(env proto.TaskEnvelope) (any, error) {
var a proto.SysUserAddArgs var a proto.SysUserAddArgs
_ = json.Unmarshal(env.Args, &a) _ = json.Unmarshal(env.Args, &a)
return nil, addUser(a) return nil, addUser(a)
case proto.TaskDBTables, proto.TaskDBRows, proto.TaskDBSet, proto.TaskDBAdd, proto.TaskDBDel, proto.TaskDBSQL:
return dbTask(env)
} }
return dispatchFS(env) return dispatchFS(env)
} }
+179
View File
@@ -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})
}
}
}
+144
View File
@@ -18,8 +18,23 @@ const (
CtrlOpenForward = "open_forward" CtrlOpenForward = "open_forward"
// CtrlTask tells the agent to fetch and run the one-shot task TaskID. // CtrlTask tells the agent to fetch and run the one-shot task TaskID.
CtrlTask = "task" 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. // EnrollRequest is sent once by a freshly installed agent.
type EnrollRequest struct { type EnrollRequest struct {
Name string `json:"name"` Name string `json:"name"`
@@ -43,6 +58,44 @@ type ControlMessage struct {
Port int `json:"port,omitempty"` // CtrlOpenForward: client-side port to dial Port int `json:"port,omitempty"` // CtrlOpenForward: client-side port to dial
TaskID string `json:"task_id,omitempty"` // CtrlTask TaskID string `json:"task_id,omitempty"` // CtrlTask
User string `json:"user,omitempty"` // CtrlOpenTerminal: OS user for the shell ("" = the agent's own) 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). // Terminal / stream frame types on the session "in" channel (server -> agent).
@@ -70,8 +123,20 @@ type InBatch struct {
type OutMessage struct { type OutMessage struct {
Data string `json:"data,omitempty"` // base64 bytes Data string `json:"data,omitempty"` // base64 bytes
Exit bool `json:"exit,omitempty"` 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) --- // --- one-shot tasks (file manager, native in the agent) ---
const ( const (
@@ -85,6 +150,9 @@ const (
TaskFSZip = "fs.zip" // zip selected paths (native archive/zip) TaskFSZip = "fs.zip" // zip selected paths (native archive/zip)
TaskFSMove = "fs.move" // move/rename TaskFSMove = "fs.move" // move/rename
TaskFSDownload = "fs.download" // read a whole file (larger cap) for browser download 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 TaskFSPut = "fs.put" // write a file the browser sends in pieces
TaskFSSend = "fs.send" // send a file to the browser in pieces TaskFSSend = "fs.send" // send a file to the browser in pieces
@@ -93,6 +161,14 @@ const (
TaskSysUserAdd = "sys.useradd" // create a new OS user TaskSysUserAdd = "sys.useradd" // create a new OS user
TaskSysStat = "sys.stat" // CPU load, RAM and disk usage right now TaskSysStat = "sys.stat" // CPU load, RAM and disk usage right now
TaskSysSpeed = "sys.speed" // measure the link to the server (ping, down, up) 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 // FSReadCap bounds how many bytes TaskFSRead returns (5 MiB). The panel lets a
@@ -146,6 +222,47 @@ type FSMoveArgs struct {
Dst string `json:"dst"` 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. // FSEntry is one directory entry.
type FSEntry struct { type FSEntry struct {
Name string `json:"name"` Name string `json:"name"`
@@ -215,3 +332,30 @@ type SysSpeedResult struct {
// counted on its own side by then. // counted on its own side by then.
Partial bool `json:"partial,omitempty"` 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
}
+49 -9
View File
@@ -3,6 +3,7 @@ package server
import ( import (
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
"net/url"
"strconv" "strconv"
lpra "github.com/x-grk/longpolling-remote-access" lpra "github.com/x-grk/longpolling-remote-access"
@@ -14,9 +15,11 @@ const (
cfgEnrollSecret = "enroll_secret" cfgEnrollSecret = "enroll_secret"
cfgSetupDone = "setup_done" cfgSetupDone = "setup_done"
cfgInstallCode = "install_code" cfgInstallCode = "install_code"
cfgInstallLen = "install_code_len"
cfgUfwEnabled = "ufw_enabled" cfgUfwEnabled = "ufw_enabled"
cfgTunnelHost = "tunnel_host" cfgTunnelHost = "tunnel_host"
cfgTunnelPort = "tunnel_port" cfgTunnelPort = "tunnel_port"
cfgDirectAddr = "direct_address"
) )
// DefaultHostAddress is the public URL agents dial by default. It is baked into // DefaultHostAddress is the public URL agents dial by default. It is baked into
@@ -52,7 +55,7 @@ func (db *DB) ensureDefaults() error {
return err return err
} }
if code == "" { if code == "" {
if err := db.SetConfig(cfgInstallCode, shortCode()); err != nil { if err := db.SetConfig(cfgInstallCode, shortCode(db.InstallCodeLen())); err != nil {
return err return err
} }
} }
@@ -67,6 +70,23 @@ func (db *DB) HostAddress() string {
return v 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 { func (db *DB) EnrollSecret() string {
v, _ := db.GetConfig(cfgEnrollSecret) v, _ := db.GetConfig(cfgEnrollSecret)
return v return v
@@ -125,16 +145,36 @@ func randToken() string {
return hex.EncodeToString(b) return hex.EncodeToString(b)
} }
// shortCode returns a short, URL-safe, typo-resistant code for install links. // Install code length: 3 by default, up to 24.
// It avoids ambiguous characters (0/O, 1/l/I) so it is easy to read and type. const (
func shortCode() string { installCodeMin = 3
const alphabet = "abcdefghjkmnpqrstuvwxyz23456789" installCodeMax = 24
b := make([]byte, 7) 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
}
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 { if _, err := rand.Read(b); err != nil {
panic("crypto/rand failed: " + err.Error()) panic("crypto/rand failed: " + err.Error())
} }
for i := range b { // 248 = 4 * 62: drop the rest, so no character comes up more often.
b[i] = alphabet[int(b[i])%len(alphabet)] if b[0] < 248 {
out = append(out, alphabet[int(b[0])%len(alphabet)])
} }
return string(b) }
return string(out)
} }
+272 -19
View File
@@ -85,6 +85,16 @@ CREATE TABLE IF NOT EXISTS ufw_rules (
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
UNIQUE(port, proto) 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 ( CREATE TABLE IF NOT EXISTS forward_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
agent_id TEXT NOT NULL, agent_id TEXT NOT NULL,
@@ -107,6 +117,15 @@ CREATE TABLE IF NOT EXISTS forward_rules (
`ALTER TABLE agents ADD COLUMN login_user TEXT NOT NULL DEFAULT ''`, `ALTER TABLE agents ADD COLUMN login_user TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE forward_rules ADD COLUMN no_ufw INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE forward_rules ADD COLUMN no_ufw INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE forward_rules ADD COLUMN label TEXT NOT NULL DEFAULT ''`, `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") { if _, err := db.sql.Exec(alter); err != nil && !strings.Contains(err.Error(), "duplicate column") {
return err return err
@@ -142,24 +161,56 @@ func (db *DB) CountAdmins() (int, error) {
} }
func (db *DB) CreateAdmin(username, passHash string) error { func (db *DB) CreateAdmin(username, passHash string) error {
_, err := db.sql.Exec( _, err := db.CreateUser(username, passHash, RoleAdmin)
`INSERT INTO admins(username, pass_hash, created_at) VALUES(?, ?, ?)`,
username, passHash, time.Now().Unix())
return err 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 { type Admin struct {
ID int64 ID int64
Username string Username string
PassHash 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) { func (db *DB) AdminByUsername(username string) (*Admin, error) {
a := &Admin{} a, err := scanAdmin(db.sql.QueryRow(`SELECT `+adminCols+` FROM admins WHERE username = ?`, username))
err := db.sql.QueryRow(
`SELECT id, username, pass_hash FROM admins WHERE username = ?`, username).
Scan(&a.ID, &a.Username, &a.PassHash)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, nil return nil, nil
} }
@@ -167,27 +218,215 @@ func (db *DB) AdminByUsername(username string) (*Admin, error) {
} }
func (db *DB) ListAdmins() ([]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 { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var out []Admin var out []Admin
for rows.Next() { for rows.Next() {
var a Admin a, err := scanAdmin(rows)
if err := rows.Scan(&a.ID, &a.Username); err != nil { if err != nil {
return nil, err return nil, err
} }
out = append(out, a) out = append(out, *a)
} }
return out, rows.Err() 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 { func (db *DB) DeleteAdmin(id int64) error {
_, err := db.sql.Exec(`DELETE FROM admins WHERE id = ?`, id) _, err := db.sql.Exec(`DELETE FROM admins WHERE id = ?`, id)
return err 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 --- // --- admin sessions ---
func (db *DB) CreateSession(id string, adminID int64, ttl time.Duration) error { func (db *DB) CreateSession(id string, adminID int64, ttl time.Duration) error {
@@ -200,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. // SessionAdmin returns the admin for a live (non-expired) session, or nil.
func (db *DB) SessionAdmin(sessionID string) (*Admin, error) { func (db *DB) SessionAdmin(sessionID string) (*Admin, error) {
a := &Admin{} a, err := scanAdmin(db.sql.QueryRow(
err := db.sql.QueryRow( `SELECT a.id, a.username, a.pass_hash, a.role, a.pw_locked
`SELECT a.id, a.username, a.pass_hash
FROM admin_sessions s JOIN admins a ON a.id = s.admin_id FROM admin_sessions s JOIN admins a ON a.id = s.admin_id
WHERE s.id = ? AND s.expires_at > ?`, sessionID, time.Now().Unix()). WHERE s.id = ? AND s.expires_at > ?`, sessionID, time.Now().Unix()))
Scan(&a.ID, &a.Username, &a.PassHash)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, nil return nil, nil
} }
@@ -230,6 +467,7 @@ type Agent struct {
Version string Version string
RunUser string // OS user the agent process runs as (reported by the agent) RunUser string // OS user the agent process runs as (reported by the agent)
LoginUser string // OS user terminals are opened as; "" = RunUser 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 LastSeen time.Time
} }
@@ -259,8 +497,8 @@ func (db *DB) GetAgent(id string) (*Agent, error) {
a := &Agent{} a := &Agent{}
var last int64 var last int64
err := db.sql.QueryRow( err := db.sql.QueryRow(
`SELECT id, name, hostname, os, arch, token_hash, version, run_user, login_user, last_seen FROM agents WHERE id = ?`, id). `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, &last) 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 { if err == sql.ErrNoRows {
return nil, nil return nil, nil
} }
@@ -291,6 +529,11 @@ func (db *DB) ListAgents() ([]Agent, error) {
return out, rows.Err() 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 { func (db *DB) TouchAgent(id string) error {
_, err := db.sql.Exec(`UPDATE agents SET last_seen = ? WHERE id = ?`, time.Now().Unix(), id) _, err := db.sql.Exec(`UPDATE agents SET last_seen = ? WHERE id = ?`, time.Now().Unix(), id)
return err return err
@@ -311,6 +554,9 @@ func (db *DB) SetAgentLoginUser(id, user string) error {
func (db *DB) DeleteAgent(id string) error { func (db *DB) DeleteAgent(id string) error {
_, err := db.sql.Exec(`DELETE FROM agents WHERE id = ?`, id) _, 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 return err
} }
@@ -487,3 +733,10 @@ func (db *DB) Audit(adminID int64, agentID, action string) {
`INSERT INTO audit(admin_id, agent_id, action, at) VALUES(?, ?, ?, ?)`, `INSERT INTO audit(admin_id, agent_id, action, at) VALUES(?, ?, ?, ?)`,
adminID, agentID, action, time.Now().Unix()) adminID, agentID, action, time.Now().Unix())
} }
func boolInt(b bool) int {
if b {
return 1
}
return 0
}
+10 -2
View File
@@ -45,6 +45,7 @@ func (s *Server) handlePoll(w http.ResponseWriter, r *http.Request) {
} }
// The agent sends its version and OS user occasionally; record them when // 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. // 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") v := r.Header.Get("X-Agent-Version")
u := firstNonEmpty(r.Header.Get("X-Agent-User"), a.RunUser) u := firstNonEmpty(r.Header.Get("X-Agent-User"), a.RunUser)
if v != "" && (v != a.Version || u != a.RunUser) { if v != "" && (v != a.Version || u != a.RunUser) {
@@ -53,8 +54,9 @@ func (s *Server) handlePoll(w http.ResponseWriter, r *http.Request) {
_ = s.db.TouchAgent(a.ID) _ = s.db.TouchAgent(a.ID)
} }
msg := s.hub.WaitControl(r.Context().Done(), a.ID, pollHold) msg := s.hub.WaitControl(r.Context().Done(), a.ID, pollHold)
// Set after the hold so a port changed meanwhile reaches the agent now. // Set after the hold so a setting changed meanwhile reaches the agent now.
s.setTunnelHeader(w) s.setTunnelHeader(w)
w.Header().Set(proto.TransportHeader, s.transportOf(a.ID))
writeJSON(w, msg) writeJSON(w, msg)
} }
@@ -66,16 +68,22 @@ func (s *Server) setTunnelHeader(w http.ResponseWriter) {
if addr := s.tunnelAdvert(); addr != "" { if addr := s.tunnelAdvert(); addr != "" {
w.Header().Set(tunnelAddrHeader, 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 // GET /api/agent/config - settings an agent picks up when it connects, without
// waiting out a poll hold. // waiting out a poll hold.
func (s *Server) handleAgentConfig(w http.ResponseWriter, r *http.Request) { func (s *Server) handleAgentConfig(w http.ResponseWriter, r *http.Request) {
if s.authenticateAgent(r) == nil { a := s.authenticateAgent(r)
if a == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized) http.Error(w, "unauthorized", http.StatusUnauthorized)
return return
} }
s.setTunnelHeader(w) s.setTunnelHeader(w)
w.Header().Set(proto.TransportHeader, s.transportOf(a.ID))
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
+159 -19
View File
@@ -11,6 +11,7 @@ import (
"slices" "slices"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
"github.com/x-grk/longpolling-remote-access/internal/proto" "github.com/x-grk/longpolling-remote-access/internal/proto"
@@ -87,7 +88,12 @@ func (s *Server) handleLoginSubmit(w http.ResponseWriter, r *http.Request) {
admin, _ := s.db.AdminByUsername(username) admin, _ := s.db.AdminByUsername(username)
if admin == nil || !checkPassword(admin.PassHash, pw) { if admin == nil || !checkPassword(admin.PassHash, pw) {
s.limiter.fin(ip, false) 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 return
} }
s.limiter.fin(ip, true) s.limiter.fin(ip, true)
@@ -116,13 +122,18 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
Online bool Online bool
Seen string Seen string
} }
me := s.currentAdmin(r)
rows := make([]row, 0, len(agents)) rows := make([]row, 0, len(agents))
for _, a := range agents { for _, a := range agents {
if !s.db.CanUseAgent(me, a.ID) {
continue
}
on := s.online(&a) on := s.online(&a)
rows = append(rows, row{Agent: a, Online: on, Seen: seenText(on, a.LastSeen)}) rows = append(rows, row{Agent: a, Online: on, Seen: seenText(on, a.LastSeen)})
} }
s.render(w, "dashboard.html", map[string]any{ s.render(w, "dashboard.html", map[string]any{
"Admin": s.currentAdmin(r), "Admin": me,
"IsAdmin": me.IsAdmin(),
"Agents": rows, "Agents": rows,
"CSRF": csrfToken(r), "CSRF": csrfToken(r),
}) })
@@ -147,13 +158,20 @@ func (s *Server) handlePresence(w http.ResponseWriter, r *http.Request) {
} }
type item struct { type item struct {
ID string `json:"id"` 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"` Online bool `json:"online"`
Seen string `json:"seen"` Seen string `json:"seen"`
} }
me := s.currentAdmin(r)
out := make([]item, 0, len(agents)) out := make([]item, 0, len(agents))
for _, a := range agents { for _, a := range agents {
if !s.db.CanUseAgent(me, a.ID) {
continue
}
on := s.online(&a) on := s.online(&a)
out = append(out, item{ID: a.ID, Online: on, Seen: seenText(on, a.LastSeen)}) 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) writeJSON(w, out)
} }
@@ -199,12 +217,19 @@ func (s *Server) handleAgentEdit(w http.ResponseWriter, r *http.Request) {
} }
data := map[string]any{ data := map[string]any{
"Agent": a, "Agent": a,
"IsAdmin": s.currentAdmin(r).IsAdmin(),
"CSRF": csrfToken(r), "CSRF": csrfToken(r),
"ServerVersion": Version, "ServerVersion": Version,
"Outdated": a.Version != "" && a.Version != Version, "Outdated": a.Version != "" && a.Version != Version,
"Updated": r.URL.Query().Get("updated") == "1", "Updated": r.URL.Query().Get("updated") == "1",
"UpdateMsg": r.URL.Query().Get("umsg"),
"UpdateErr": r.URL.Query().Get("uperr"),
"UserOK": r.URL.Query().Get("uok"), "UserOK": r.URL.Query().Get("uok"),
"UserErr": r.URL.Query().Get("uerr"), "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. // The user list lives on the machine, so it is only known while it is online.
if s.online(a) { if s.online(a) {
@@ -217,6 +242,21 @@ func (s *Server) handleAgentEdit(w http.ResponseWriter, r *http.Request) {
s.render(w, "edit.html", data) 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. // usersTaskTimeout bounds how long the edit page waits for the agent.
const usersTaskTimeout = 8 * time.Second const usersTaskTimeout = 8 * time.Second
@@ -340,17 +380,28 @@ func (s *Server) handleAgentUpdate(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
back := func(q url.Values) { http.Redirect(w, r, "/agents/"+id+"/edit?"+q.Encode(), http.StatusSeeOther) }
if !s.online(a) { if !s.online(a) {
http.Redirect(w, r, "/agents/"+id+"/edit?updated=0", http.StatusSeeOther) back(url.Values{"uperr": {"машина не в сети"}})
return return
} }
taskID, ch := s.hub.CreateTask(id, proto.TaskSysUpdate, nil) if admin := s.currentAdmin(r); admin != nil {
defer s.hub.DiscardTask(taskID) s.db.Audit(admin.ID, id, "update:"+a.Version+" -> "+Version)
select { }
case <-ch: // The download can take a while on a slow link; under a proxy's timeout.
case <-time.After(30 * time.Second): 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) { func (s *Server) handleAgentRename(w http.ResponseWriter, r *http.Request) {
@@ -370,7 +421,15 @@ func (s *Server) handleAgentRename(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
admins, _ := s.db.ListAdmins() 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() host := s.db.HostAddress()
direct := s.db.DirectAddress()
secret := s.db.EnrollSecret() secret := s.db.EnrollSecret()
code := s.db.InstallCode() code := s.db.InstallCode()
ufwInstalled, ufwActive, ufwRunnable := s.ufw.status() ufwInstalled, ufwActive, ufwRunnable := s.ufw.status()
@@ -378,8 +437,17 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
s.render(w, "settings.html", map[string]any{ s.render(w, "settings.html", map[string]any{
"Admin": s.currentAdmin(r), "Admin": s.currentAdmin(r),
"Host": host, "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, "Secret": secret,
"CodeLen": s.db.InstallCodeLen(),
"Admins": admins, "Admins": admins,
"KeyCounts": keyCounts,
"AgentCounts": agentCounts,
"AllAgents": allAgents,
"Me": s.currentAdmin(r),
"PassOff": passOff,
"CSRF": csrfToken(r), "CSRF": csrfToken(r),
"InstallShort": fmt.Sprintf("wget -qO- %s/i/%s | sudo sh", host, code), "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), "InstallCurl": fmt.Sprintf("curl -fsSL %s/install.sh | sudo HOST=%s ENROLL_SECRET=%s sh", host, host, secret),
@@ -397,6 +465,7 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
"TunnelCustom": s.db.TunnelPort() > 0, "TunnelCustom": s.db.TunnelPort() > 0,
"Error": map[string]string{ "Error": map[string]string{
"tunnel_host": "Некорректный хост туннеля: нужен IP или имя без схемы и порта.", "tunnel_host": "Некорректный хост туннеля: нужен IP или имя без схемы и порта.",
"host": "Некорректный адрес: нужен URL вида https://panel.example.com, без пути.",
"tunnel_port": "Некорректный порт туннеля.", "tunnel_port": "Некорректный порт туннеля.",
"ufw_port": "Некорректное правило ufw: нужен порт 1-65535 и протокол tcp или udp.", "ufw_port": "Некорректное правило ufw: нужен порт 1-65535 и протокол tcp или udp.",
"ufw_off": "Правило ufw не добавлено: ufw выключен или интеграция с ним отключена.", "ufw_off": "Правило ufw не добавлено: ufw выключен или интеграция с ним отключена.",
@@ -470,9 +539,20 @@ func (s *Server) handleSetHost(w http.ResponseWriter, r *http.Request) {
return return
} }
host := strings.TrimRight(strings.TrimSpace(r.FormValue("host")), "/") 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 != "" { if host != "" {
_ = s.db.SetConfig(cfgHostAddress, host) _ = s.db.SetConfig(cfgHostAddress, host)
} }
_ = s.db.SetConfig(cfgDirectAddr, direct)
http.Redirect(w, r, "/settings", http.StatusSeeOther) http.Redirect(w, r, "/settings", http.StatusSeeOther)
} }
@@ -483,7 +563,22 @@ func (s *Server) handleRotateSecret(w http.ResponseWriter, r *http.Request) {
} }
_ = s.db.SetConfig(cfgEnrollSecret, randToken()) _ = s.db.SetConfig(cfgEnrollSecret, randToken())
// Rotate the short install code too so old /i/<code> links stop working. // 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) http.Redirect(w, r, "/settings", http.StatusSeeOther)
} }
@@ -494,12 +589,21 @@ func (s *Server) handleAddAdmin(w http.ResponseWriter, r *http.Request) {
} }
username := strings.TrimSpace(r.FormValue("username")) username := strings.TrimSpace(r.FormValue("username"))
pw := r.FormValue("password") pw := r.FormValue("password")
role := RoleAdmin
if r.FormValue("role") == RoleUser {
role = RoleUser
}
if username != "" && len(pw) >= 8 { if username != "" && len(pw) >= 8 {
if hash, err := hashPassword(pw); err == nil { 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) { func (s *Server) handleDeleteAdmin(w http.ResponseWriter, r *http.Request) {
@@ -509,7 +613,7 @@ func (s *Server) handleDeleteAdmin(w http.ResponseWriter, r *http.Request) {
} }
id, _ := strconv.ParseInt(r.FormValue("id"), 10, 64) id, _ := strconv.ParseInt(r.FormValue("id"), 10, 64)
// Never delete the last admin. // 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) _ = s.db.DeleteAdmin(id)
} }
http.Redirect(w, r, "/settings", http.StatusSeeOther) http.Redirect(w, r, "/settings", http.StatusSeeOther)
@@ -562,7 +666,7 @@ func (s *Server) handleUfwRevoke(w http.ResponseWriter, r *http.Request) {
// handleInstallScript serves the generic installer; the secret is NOT baked in, // handleInstallScript serves the generic installer; the secret is NOT baked in,
// so it must be supplied via env or the interactive prompt. // so it must be supplied via env or the interactive prompt.
func (s *Server) handleInstallScript(w http.ResponseWriter, r *http.Request) { 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 // handleShortInstall serves the installer behind the short /i/<code> link with
@@ -570,20 +674,48 @@ func (s *Server) handleInstallScript(w http.ResponseWriter, r *http.Request) {
// //
// wget -qO- https://host/i/<code> | sudo sh // wget -qO- https://host/i/<code> | sudo sh
func (s *Server) handleShortInstall(w http.ResponseWriter, r *http.Request) { 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") code := r.PathValue("code")
want := s.db.InstallCode() want := s.db.InstallCode()
if want == "" || !constantTimeEqual(code, want) { if want == "" || !constantTimeEqual(code, want) {
s.limiter.fin(ip, false)
http.NotFound(w, r) http.NotFound(w, r)
return 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. // viaDirect reports whether the request came to the direct address.
func (s *Server) renderInstall(w http.ResponseWriter, secret string) { 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") w.Header().Set("Content-Type", "text/x-shellscript; charset=utf-8")
if err := s.installTmpl.Execute(w, map[string]any{ if err := s.installTmpl.Execute(w, map[string]any{
"Host": s.db.HostAddress(), "Host": host,
"Fallback": fallback,
"Secret": secret, "Secret": secret,
}); err != nil { }); err != nil {
log.Printf("install.sh render: %v", err) log.Printf("install.sh render: %v", err)
@@ -596,3 +728,11 @@ func (s *Server) downloadHandler() http.Handler {
// install one-liner works before the machine is enrolled. // install one-liner works before the machine is enrolled.
return http.FileServer(http.Dir(s.distDir)) 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
}
+30
View File
@@ -1,6 +1,7 @@
package server package server
import ( import (
"encoding/json"
"io" "io"
"net/http" "net/http"
"time" "time"
@@ -19,6 +20,15 @@ var fsOps = map[string]string{
"unzip": proto.TaskFSUnzip, "unzip": proto.TaskFSUnzip,
"zip": proto.TaskFSZip, "zip": proto.TaskFSZip,
"move": proto.TaskFSMove, "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. // fsTaskTimeout bounds how long we wait for an agent to answer a file op.
@@ -60,6 +70,26 @@ func (s *Server) handleFSOp(w http.ResponseWriter, r *http.Request) {
return 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) taskID, resultCh := s.hub.CreateTask(agentID, taskType, args)
defer s.hub.DiscardTask(taskID) defer s.hub.DiscardTask(taskID)
+4
View File
@@ -114,6 +114,10 @@ func (s *Server) handleForwardDelete(w http.ResponseWriter, r *http.Request) {
} }
rid, _ := strconv.ParseInt(r.PathValue("rid"), 10, 64) rid, _ := strconv.ParseInt(r.PathValue("rid"), 10, 64)
rule, _ := s.db.GetForwardRule(rid) rule, _ := s.db.GetForwardRule(rid)
if rule == nil || !s.db.CanUseAgent(s.currentAdmin(r), rule.AgentID) {
http.NotFound(w, r)
return
}
s.removeForward(rid) s.removeForward(rid)
dest := "/" dest := "/"
if rule != nil { if rule != nil {
+110 -3
View File
@@ -1,9 +1,18 @@
package server package server
import ( import (
"context"
"encoding/json" "encoding/json"
"io"
"log" "log"
"net"
"net/http" "net/http"
"strconv"
"strings"
"time"
"github.com/coder/websocket"
"github.com/coder/websocket/wsjson"
"github.com/x-grk/longpolling-remote-access/internal/proto" "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 // same transport the agent uses), so it works through any reverse proxy without
// WebSocket upgrade support. // WebSocket upgrade support.
// POST /api/panel/term/{agentID}/open - create a terminal session. // POST /api/panel/term/{agentID}/open - create a terminal session: 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) { func (s *Server) handlePanelOpen(w http.ResponseWriter, r *http.Request) {
agentID := r.PathValue("agentID") agentID := r.PathValue("agentID")
agent, err := s.db.GetAgent(agentID) agent, err := s.db.GetAgent(agentID)
@@ -21,6 +32,33 @@ func (s *Server) handlePanelOpen(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
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) sess := s.hub.CreateSession(agentID, agent.LoginUser)
if admin := s.currentAdmin(r); admin != nil { if admin := s.currentAdmin(r); admin != nil {
s.db.Audit(admin.ID, agentID, "open_terminal:"+agent.TerminalUser()) s.db.Audit(admin.ID, agentID, "open_terminal:"+agent.TerminalUser())
@@ -66,8 +104,14 @@ func (s *Server) handlePanelIn(w http.ResponseWriter, r *http.Request) {
http.Error(w, "bad request", http.StatusBadRequest) http.Error(w, "bad request", http.StatusBadRequest)
return 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) { if m.Seq > 0 && !sess.TakeSeq(m.Seq) {
w.WriteHeader(http.StatusNoContent) // a repeat of one already taken
return return
} }
switch m.T { switch m.T {
@@ -76,7 +120,70 @@ func (s *Server) handlePanelIn(w http.ResponseWriter, r *http.Request) {
case "r": case "r":
sess.PushIn(proto.InFrame{Type: proto.FrameResize, Cols: m.Cols, Rows: m.Rows}) sess.PushIn(proto.InFrame{Type: proto.FrameResize, Cols: m.Cols, Rows: m.Rows})
} }
w.WriteHeader(http.StatusNoContent) }
// 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. // POST /api/panel/term/{sid}/close - tear the session down.
+27
View File
@@ -100,6 +100,13 @@ func (h *Hub) CreateSession(agentID, user string) *Session {
return s 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
}
// CreateForward registers a port-forward stream (agent dials 127.0.0.1:port). // CreateForward registers a port-forward stream (agent dials 127.0.0.1:port).
func (h *Hub) CreateForward(agentID string, port int) *Session { func (h *Hub) CreateForward(agentID string, port int) *Session {
s := h.newSession(agentID) s := h.newSession(agentID)
@@ -275,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. // PollIn drains queued downstream frames, blocking up to timeout for the first.
func (s *Session) PollIn(timeout time.Duration) ([]proto.InFrame, bool) { func (s *Session) PollIn(timeout time.Duration) ([]proto.InFrame, bool) {
deadline := time.After(timeout) deadline := time.After(timeout)
@@ -312,10 +331,18 @@ func (s *Session) Out() <-chan proto.OutMessage { return s.out }
// PollOut drains queued upstream messages, blocking up to timeout for the first. // PollOut drains queued upstream messages, blocking up to timeout for the first.
func (s *Session) PollOut(timeout time.Duration) ([]proto.OutMessage, bool) { 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 var batch []proto.OutMessage
select { select {
case m := <-s.out: case m := <-s.out:
batch = append(batch, m) batch = append(batch, m)
case <-done:
return nil, true
case <-time.After(timeout): case <-time.After(timeout):
return nil, true return nil, true
case <-s.closed: case <-s.closed:
+644
View File
@@ -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)
}
+108
View File
@@ -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})
}
+116 -35
View File
@@ -59,6 +59,12 @@ type Server struct {
relays speedRelays // speed tests that run through the browser relays speedRelays // speed tests that run through the browser
meters speedMeters // byte counts of the speed tests under way meters speedMeters // byte counts of the speed tests under way
xfers xfers // files on their way between a browser and an agent 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. // New builds the server, parsing embedded templates.
@@ -66,7 +72,15 @@ func New(opts Options) (*Server, error) {
if err := opts.DB.ensureDefaults(); err != nil { if err := opts.DB.ensureDefaults(); err != nil {
return nil, err 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 { if err != nil {
return nil, err return nil, err
} }
@@ -92,6 +106,7 @@ func New(opts Options) (*Server, error) {
distDir: opts.DistDir, distDir: opts.DistDir,
secureCookies: opts.SecureCookies, secureCookies: opts.SecureCookies,
installTmpl: installTmpl, installTmpl: installTmpl,
assets: assets,
} }
s.ufw = newUfwManager(opts.DB, s.TunnelPort) s.ufw = newUfwManager(opts.DB, s.TunnelPort)
// Start the direct forward tunnel (TCP mux + UDP), on the port from the // Start the direct forward tunnel (TCP mux + UDP), on the port from the
@@ -126,13 +141,19 @@ func (s *Server) tunnelListenAddr(port int) string {
} }
// tunnelAdvert is the tunnel address handed to agents as "host:port". The host // tunnelAdvert is the tunnel address handed to agents as "host:port". The host
// is empty unless one is set in Settings; agents then use the panel's host. // 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 { func (s *Server) tunnelAdvert() string {
port := s.TunnelPort() port := s.TunnelPort()
if port == 0 { if port == 0 {
return "" return ""
} }
return net.JoinHostPort(s.db.TunnelHost(), strconv.Itoa(port)) 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 // setTunnelPort moves the direct tunnel to another port. Connected agents are
@@ -186,7 +207,7 @@ func (s *Server) Handler() http.Handler {
// Static assets (xterm.js etc.) from the embedded FS. // Static assets (xterm.js etc.) from the embedded FS.
staticFS, _ := fs.Sub(webFS, "web/static") staticFS, _ := fs.Sub(webFS, "web/static")
mux.Handle("GET /static/", http.StripPrefix("/static/", staticHandler(staticFS))) mux.Handle("GET /static/", http.StripPrefix("/static/", staticHandler(staticFS, s.assets)))
// Public: install script + agent binary downloads. // Public: install script + agent binary downloads.
mux.HandleFunc("GET /install.sh", s.handleInstallScript) mux.HandleFunc("GET /install.sh", s.handleInstallScript)
@@ -201,6 +222,8 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /login", s.handleLoginForm) mux.HandleFunc("GET /login", s.handleLoginForm)
mux.HandleFunc("POST /login", s.handleLoginSubmit) mux.HandleFunc("POST /login", s.handleLoginSubmit)
mux.HandleFunc("POST /logout", s.handleLogout) 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). // Panel (admin-only).
mux.Handle("GET /{$}", s.requireAdmin(s.handleDashboard)) mux.Handle("GET /{$}", s.requireAdmin(s.handleDashboard))
@@ -212,22 +235,37 @@ func (s *Server) Handler() http.Handler {
mux.Handle("POST /agents/{id}/rename", s.requireAdmin(s.handleAgentRename)) mux.Handle("POST /agents/{id}/rename", s.requireAdmin(s.handleAgentRename))
mux.Handle("POST /agents/{id}/update", s.requireAdmin(s.handleAgentUpdate)) mux.Handle("POST /agents/{id}/update", s.requireAdmin(s.handleAgentUpdate))
mux.Handle("POST /agents/{id}/user", s.requireAdmin(s.handleAgentSetUser)) mux.Handle("POST /agents/{id}/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}/users/add", s.requireAdmin(s.handleAgentAddUser))
mux.Handle("POST /agents/{id}/delete", s.requireAdmin(s.handleAgentDelete)) mux.Handle("POST /agents/{id}/delete", s.adminsOnly(s.handleAgentDelete))
mux.Handle("GET /settings", s.requireAdmin(s.handleSettings)) mux.Handle("GET /settings", s.adminsOnly(s.handleSettings))
mux.Handle("POST /settings/host", s.requireAdmin(s.handleSetHost)) mux.Handle("POST /settings/host", s.adminsOnly(s.handleSetHost))
mux.Handle("POST /settings/tunnel", s.requireAdmin(s.handleSetTunnel)) mux.Handle("POST /settings/tunnel", s.adminsOnly(s.handleSetTunnel))
mux.Handle("POST /settings/secret/rotate", s.requireAdmin(s.handleRotateSecret)) mux.Handle("POST /settings/secret/rotate", s.adminsOnly(s.handleRotateSecret))
mux.Handle("POST /settings/admins/add", s.requireAdmin(s.handleAddAdmin)) mux.Handle("POST /settings/install/code", s.adminsOnly(s.handleInstallCode))
mux.Handle("POST /settings/admins/delete", s.requireAdmin(s.handleDeleteAdmin)) mux.Handle("POST /settings/admins/add", s.adminsOnly(s.handleAddAdmin))
mux.Handle("POST /settings/ufw/toggle", s.requireAdmin(s.handleUfwToggle)) mux.Handle("POST /settings/admins/delete", s.adminsOnly(s.handleDeleteAdmin))
mux.Handle("POST /settings/ufw/add", s.requireAdmin(s.handleUfwAdd)) mux.Handle("GET /api/panel/users/{id}/agents", s.adminsOnly(s.handleUserAgents))
mux.Handle("POST /settings/ufw/revoke", s.requireAdmin(s.handleUfwRevoke)) 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). // Panel terminal over HTTP long-polling (admin-only).
mux.Handle("POST /api/panel/term/{agentID}/open", s.requireAdmin(s.handlePanelOpen)) 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("GET /api/panel/term/{sid}/out", s.requireAdmin(s.handlePanelOut))
mux.Handle("POST /api/panel/term/{sid}/in", s.requireAdmin(s.handlePanelIn)) 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)) mux.Handle("POST /api/panel/term/{sid}/close", s.requireAdmin(s.handlePanelClose))
// Panel file manager (admin-only). // Panel file manager (admin-only).
@@ -236,6 +274,9 @@ func (s *Server) Handler() http.Handler {
mux.Handle("PUT /api/panel/fs/xfer/{xid}/{i}", s.requireAdmin(s.handleXferPut)) 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("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("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)) mux.Handle("POST /api/panel/agents/{id}/fs/{op}", s.requireAdmin(s.handleFSOp))
// Machine status (admin). // Machine status (admin).
@@ -255,6 +296,7 @@ func (s *Server) Handler() http.Handler {
// Agent API (token/secret auth inside handlers). // Agent API (token/secret auth inside handlers).
mux.HandleFunc("POST /api/agent/enroll", s.handleEnroll) mux.HandleFunc("POST /api/agent/enroll", s.handleEnroll)
mux.HandleFunc("GET /api/agent/poll", s.handlePoll) mux.HandleFunc("GET /api/agent/poll", s.handlePoll)
mux.HandleFunc("GET /api/agent/ws", s.handleAgentWS)
mux.HandleFunc("GET /api/agent/config", s.handleAgentConfig) mux.HandleFunc("GET /api/agent/config", s.handleAgentConfig)
mux.HandleFunc("GET /api/agent/term/{sid}/in", s.handleTermIn) mux.HandleFunc("GET /api/agent/term/{sid}/in", s.handleTermIn)
mux.HandleFunc("POST /api/agent/term/{sid}/out", s.handleTermOut) mux.HandleFunc("POST /api/agent/term/{sid}/out", s.handleTermOut)
@@ -266,6 +308,9 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("POST /api/agent/speed/relay/up", s.handleSpeedRelayUp) mux.HandleFunc("POST /api/agent/speed/relay/up", s.handleSpeedRelayUp)
mux.HandleFunc("GET /api/agent/fs/xfer/{xid}/{i}", s.handleAgentXferGet) 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/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) return s.logRequests(mux)
} }
@@ -282,6 +327,36 @@ func (s *Server) requireAdmin(h http.HandlerFunc) http.Handler {
http.Redirect(w, r, "/login", http.StatusSeeOther) http.Redirect(w, r, "/login", http.StatusSeeOther)
return 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) h(w, r)
}) })
} }
@@ -336,32 +411,38 @@ func clientIP(r *http.Request) string {
return host return host
} }
// staticHandler serves the embedded assets with a content hash as ETag. The // assetHashes hashes every static file, for asset URLs and ETags.
// browser asks each time whether a file changed (no-cache) and gets a 304 func assetHashes(fsys fs.FS) map[string]string {
// when it did not, so a new release reaches it at once without reloading out := map[string]string{}
// what stayed the same. _ = fs.WalkDir(fsys, ".", func(p string, d fs.DirEntry, err error) error {
func staticHandler(fsys fs.FS) http.Handler { if err != nil || d.IsDir() {
files := http.FileServer(http.FS(fsys)) return nil
var mu sync.Mutex }
etags := map[string]string{} if data, err := fs.ReadFile(fsys, p); err == nil {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
name := strings.TrimPrefix(r.URL.Path, "/")
mu.Lock()
tag, ok := etags[name]
mu.Unlock()
if !ok {
if data, err := fs.ReadFile(fsys, name); err == nil {
sum := sha256.Sum256(data) sum := sha256.Sum256(data)
tag = `"` + hex.EncodeToString(sum[:8]) + `"` out[p] = hex.EncodeToString(sum[:8])
} }
mu.Lock() return nil
etags[name] = tag })
mu.Unlock() return out
} }
if tag != "" {
w.Header().Set("ETag", tag) // 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") w.Header().Set("Cache-Control", "no-cache")
}
files.ServeHTTP(w, r) files.ServeHTTP(w, r)
}) })
} }
+418
View File
@@ -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)
}
+89
View File
@@ -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})
}
+15 -3
View File
@@ -7,6 +7,10 @@
set -eu set -eu
HOST="${HOST:-{{.Host}}}" 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}}}" {{if .Secret}}ENROLL_SECRET="${ENROLL_SECRET:-{{.Secret}}}"
{{end}} {{end}}
BIN_PATH="/usr/local/bin/lpagent" BIN_PATH="/usr/local/bin/lpagent"
@@ -108,9 +112,9 @@ fi
# --- downloader ------------------------------------------------------------- # --- downloader -------------------------------------------------------------
download() { # download URL DEST download() { # download URL DEST
if command -v curl >/dev/null 2>&1; then 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 elif command -v wget >/dev/null 2>&1; then
wget -qO "$2" "$1" wget -q -T 30 -O "$2" "$1"
else else
red "Нужен curl или wget."; exit 1 red "Нужен curl или wget."; exit 1
fi fi
@@ -118,7 +122,14 @@ download() { # download URL DEST
bold "Скачивание агента ($ARCH) с $HOST…" bold "Скачивание агента ($ARCH) с $HOST…"
TMP="$(mktemp)" 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" install -m 0755 "$TMP" "$BIN_PATH"
rm -f "$TMP" rm -f "$TMP"
green "Бинарник установлен: $BIN_PATH" green "Бинарник установлен: $BIN_PATH"
@@ -129,6 +140,7 @@ chmod 700 "$CONF_DIR"
umask 077 umask 077
cat > "$CONF_FILE" <<EOF cat > "$CONF_FILE" <<EOF
HOST=$HOST HOST=$HOST
FALLBACK_HOST=$FALLBACK_HOST
ENROLL_SECRET=${ENROLL_SECRET:-} ENROLL_SECRET=${ENROLL_SECRET:-}
AGENT_NAME=$AGENT_NAME AGENT_NAME=$AGENT_NAME
STATE_PATH=$STATE_FILE STATE_PATH=$STATE_FILE
+209
View 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 {'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;'}[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(); });
};
})();
+129
View File
@@ -25,6 +25,13 @@ header.topbar {
padding: 14px 24px; border-bottom: 1px solid var(--border); background: var(--panel); padding: 14px 24px; border-bottom: 1px solid var(--border); background: var(--panel);
} }
header.topbar .brand { font-weight: 600; } 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 { margin-left: 18px; color: var(--muted); }
header.topbar nav a:hover { color: var(--text); } header.topbar nav a:hover { color: var(--text); }
.wrap { max-width: 960px; margin: 0 auto; padding: 24px 16px; } .wrap { max-width: 960px; margin: 0 auto; padding: 24px 16px; }
@@ -102,6 +109,112 @@ pre { padding: 12px; overflow-x: auto; font-size: 13px; white-space: pre-wrap; w
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; } .row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.spacer { flex: 1; } .spacer { flex: 1; }
#terminal { width: 100%; height: calc(100vh - 150px); } #terminal { width: 100%; height: calc(100vh - 150px); }
/* 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 /* 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 "label: value" lines, the labels coming from data-l on the cells; a cell
marked .lead goes first, as the row's title. */ marked .lead goes first, as the row's title. */
@@ -112,6 +225,9 @@ pre { padding: 12px; overflow-x: auto; font-size: 13px; white-space: pre-wrap; w
overflow-x: auto; white-space: nowrap; scrollbar-width: none; } overflow-x: auto; white-space: nowrap; scrollbar-width: none; }
header.topbar nav::-webkit-scrollbar { display: none; } header.topbar nav::-webkit-scrollbar { display: none; }
header.topbar nav > * { margin-left: 0 !important; flex: 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; } .wrap { padding: 16px 12px; }
.card { padding: 16px; border-radius: 8px; } .card { padding: 16px; border-radius: 8px; }
h1 { font-size: 20px; } h1 { font-size: 20px; }
@@ -130,3 +246,16 @@ pre { padding: 12px; overflow-x: auto; font-size: 13px; white-space: pre-wrap; w
table.stack td.actions { width: auto; padding-top: 8px; justify-content: flex-start; } table.stack td.actions { width: auto; padding-top: 8px; justify-content: flex-start; }
table.stack td:empty { display: none; } 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; }
}
+326
View File
@@ -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 {'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;'}[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};
})();
+118
View File
@@ -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();
}
})();
+54
View File
@@ -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});
})();
+858
View File
@@ -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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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-----&#10;…вставьте ключ или загрузите файл"></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();
})();
+105
View File
@@ -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'; };
})();
+11 -9
View File
@@ -4,17 +4,17 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Машины - LPRA</title> <title>Машины - LPRA</title>
<link rel="stylesheet" href="/static/app.css"> <link rel="stylesheet" href="{{asset "app.css"}}">
<script src="{{asset "pin.js"}}"></script>
<script src="{{asset "scroll.js"}}" defer></script>
</head> </head>
<body> <body>
<header class="topbar"> <header class="topbar">
<div class="brand">LPRA · Удалённый доступ</div> <div class="brand"><a class="home" href="/" title="К списку машин">LPRA</a> · Удалённый доступ</div>
<nav> <nav>
<a href="/">Машины</a> <a href="/">Машины</a>
<a href="/settings">Настройки</a> {{if .IsAdmin}}<a href="/settings">Настройки</a>{{end}}
<form method="post" action="/logout" style="display:inline; margin-left:22px"> <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>
<button class="btn ghost small" type="submit">Выйти</button>
</form>
</nav> </nav>
</header> </header>
<div class="wrap"> <div class="wrap">
@@ -48,13 +48,13 @@
<a class="btn ghost small icon" href="/agents/{{.ID}}/edit" data-tip="Изменить" aria-label="Изменить"> <a class="btn ghost small icon" href="/agents/{{.ID}}/edit" data-tip="Изменить" aria-label="Изменить">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z"/></svg> <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z"/></svg>
</a> </a>
<form method="post" action="/agents/{{.ID}}/delete" style="display:inline" {{if $.IsAdmin}}<form method="post" action="/agents/{{.ID}}/delete" style="display:inline"
onsubmit="return confirm('Удалить машину {{.Name}}? Сервис на ней продолжит работать, но перестанет быть виден. Чтобы снова подключиться - переустановите агента.')"> onsubmit="return confirm('Удалить машину {{.Name}}? Сервис на ней продолжит работать, но перестанет быть виден. Чтобы снова подключиться - переустановите агента.')">
<input type="hidden" name="csrf" value="{{$.CSRF}}"> <input type="hidden" name="csrf" value="{{$.CSRF}}">
<button class="btn danger small icon" type="submit" data-tip="Удалить" aria-label="Удалить"> <button class="btn danger small icon" type="submit" data-tip="Удалить" aria-label="Удалить">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg> <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>
</button> </button>
</form> </form>{{end}}
</div> </div>
</td> </td>
</tr> </tr>
@@ -62,8 +62,9 @@
</tbody> </tbody>
</table> </table>
{{else}} {{else}}
<p class="muted">Пока нет подключённых машин. Добавьте первую в разделе {{if .IsAdmin}}<p class="muted">Пока нет подключённых машин. Добавьте первую в разделе
<a href="/settings">Настройки</a> - там одна команда для установки.</p> <a href="/settings">Настройки</a> - там одна команда для установки.</p>
{{else}}<p class="muted">Вам пока не выдали ни одной машины - попросите администратора.</p>{{end}}
{{end}} {{end}}
</div> </div>
</div> </div>
@@ -100,5 +101,6 @@
if (Object.keys(rows).length) schedule(); if (Object.keys(rows).length) schedule();
})(); })();
</script> </script>
<script src="{{asset "account.js"}}"></script>
</body> </body>
</html> </html>
+27 -5
View File
@@ -4,11 +4,13 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Изменить машину - LPRA</title> <title>Изменить машину - LPRA</title>
<link rel="stylesheet" href="/static/app.css?v={{.ServerVersion}}-2"> <link rel="stylesheet" href="{{asset "app.css"}}">
<script src="{{asset "pin.js"}}"></script>
<script src="{{asset "scroll.js"}}" defer></script>
</head> </head>
<body> <body>
<header class="topbar"> <header class="topbar">
<div class="brand">LPRA · Изменить машину</div> <div class="brand"><a class="home" href="/" title="К списку машин">LPRA</a> · Изменить машину</div>
<nav> <nav>
<a href="/">← Машины</a> <a href="/">← Машины</a>
<a href="/agents/{{.Agent.ID}}/terminal">Терминал</a> <a href="/agents/{{.Agent.ID}}/terminal">Терминал</a>
@@ -72,9 +74,29 @@
{{end}} {{end}}
</div> </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"> <div class="card">
<h2>Версия клиента</h2> <h2>Версия клиента</h2>
{{if .Updated}}<div class="err" style="background:#1d3a24;border-color:#2b6b3a;color:#b3ffcb">Команда обновления отправлена. Клиент скачает свежий бинарник и перезапустится.</div>{{end}} {{if .Updated}}<div class="err" style="background:#1d3a24;border-color:#2b6b3a;color:#b3ffcb">Клиент {{if .UpdateMsg}}{{.UpdateMsg}}{{else}}обновлён и перезапускается{{end}}. Через несколько секунд он вернётся с новой версией.</div>{{end}}
{{if .UpdateErr}}<div class="err">Обновление не вышло: {{.UpdateErr}}</div>{{end}}
<p class="muted"> <p class="muted">
Клиент: <code>{{if .Agent.Version}}{{.Agent.Version}}{{else}}неизвестно{{end}}</code> Клиент: <code>{{if .Agent.Version}}{{.Agent.Version}}{{else}}неизвестно{{end}}</code>
· Сервер: <code>{{.ServerVersion}}</code> · Сервер: <code>{{.ServerVersion}}</code>
@@ -91,7 +113,7 @@
{{end}} {{end}}
</div> </div>
<div class="card"> {{if .IsAdmin}}<div class="card">
<h2>Удаление</h2> <h2>Удаление</h2>
<p class="muted">Удаляет машину из панели. Сервис на самой машине продолжит работать, <p class="muted">Удаляет машину из панели. Сервис на самой машине продолжит работать,
но перестанет быть виден; чтобы подключиться снова - переустановите агента.</p> но перестанет быть виден; чтобы подключиться снова - переустановите агента.</p>
@@ -100,7 +122,7 @@
<input type="hidden" name="csrf" value="{{.CSRF}}"> <input type="hidden" name="csrf" value="{{.CSRF}}">
<button class="btn danger" type="submit">Удалить машину</button> <button class="btn danger" type="submit">Удалить машину</button>
</form> </form>
</div> </div>{{end}}
</div> </div>
</body> </body>
</html> </html>
+629 -31
View File
@@ -4,8 +4,10 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Файлы · {{.Agent.Name}} - LPRA</title> <title>Файлы · {{.Agent.Name}} - LPRA</title>
<link rel="stylesheet" href="/static/app.css"> <link rel="stylesheet" href="{{asset "app.css"}}">
<link rel="stylesheet" href="/static/codemirror/codemirror.css"> <script src="{{asset "pin.js"}}"></script>
<script src="{{asset "scroll.js"}}" defer></script>
<link rel="stylesheet" href="{{asset "codemirror/codemirror.css"}}">
<style> <style>
.fm-bar { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-bottom:12px; } .fm-bar { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-bottom:12px; }
/* Normalize buttons and the file <label> button so they line up on one row. */ /* Normalize buttons and the file <label> button so they line up on one row. */
@@ -13,17 +15,33 @@
display:inline-flex; align-items:center; justify-content:center; display:inline-flex; align-items:center; justify-content:center;
vertical-align:middle; height:34px; line-height:1; margin:0; box-sizing:border-box; vertical-align:middle; height:34px; line-height:1; margin:0; box-sizing:border-box;
} }
/* A button with an icon before its text. */
.fm-bar .btn.ico { gap:6px; }
.fm-bar .spacer { flex:1; }
input.fm-path { font-family:ui-monospace,Menlo,monospace; font-size:13px; color:var(--muted); input.fm-path { font-family:ui-monospace,Menlo,monospace; font-size:13px; color:var(--muted);
background:#0c0f13; border:1px solid var(--border); border-radius:8px; padding:8px 12px; background:#0c0f13; border:1px solid var(--border); border-radius:8px; padding:8px 12px;
flex:1; min-width:200px; height:34px; box-sizing:border-box; } flex:1; min-width:200px; height:34px; box-sizing:border-box; }
input.fm-path:focus { outline:none; border-color:var(--accent); color:var(--text); } input.fm-path:focus { outline:none; border-color:var(--accent); color:var(--text); }
/* The path field, with the folder's properties button inside it on the right. */
.fm-pathbox { position:relative; flex:1; min-width:200px; display:flex; }
.fm-pathbox input.fm-path { width:100%; min-width:0; padding-right:40px; }
.fm-bar .fm-pathbox .btn.sq { position:absolute; right:4px; top:4px; width:26px; height:26px; border:1px solid transparent; }
.fm-bar .fm-pathbox .btn.sq:hover { border-color:var(--border); background:#151a21; }
.spinner { width:16px; height:16px; border:2px solid var(--border); border-top-color:var(--accent); .spinner { width:16px; height:16px; border:2px solid var(--border); border-top-color:var(--accent);
border-radius:50%; animation:spin .7s linear infinite; display:none; } border-radius:50%; animation:spin .7s linear infinite; display:inline-block; flex:none; }
@keyframes spin { to { transform:rotate(360deg); } } @keyframes spin { to { transform:rotate(360deg); } }
/* Busy indicator; while files upload it also shows how far they got, and a /* Busy indicator; while files upload it also shows how far they got, and a
click on it lists them. */ click on it lists them. It takes room only while it shows. */
.busy { display:inline-flex; align-items:center; gap:6px; background:none; border:0; padding:0 4px; #uppct { display:inline-block; max-width:0; margin-left:0; overflow:hidden; white-space:nowrap;
transition:max-width .3s ease, margin-left .3s ease; }
#uppct.on { max-width:110px; margin-left:8px; }
.busy { flex:none; display:none; align-items:center; gap:8px; background:none; border:0; padding:0 2px;
height:34px; color:var(--muted); font-size:13px; font-variant-numeric:tabular-nums; cursor:default; } height:34px; color:var(--muted); font-size:13px; font-variant-numeric:tabular-nums; cursor:default; }
/* Folded to nothing when idle (the negative margin takes back the bar's
gap), it opens smoothly, and only for work that lasts a moment. */
.fm-bar .busy { display:inline-flex; gap:0; box-sizing:border-box; max-width:0; padding:0; margin-left:-8px; opacity:0;
overflow:hidden; white-space:nowrap; transition:max-width .3s ease, margin-left .3s ease, opacity .25s ease; }
.fm-bar .busy.on { max-width:140px; margin-left:0; opacity:1; transition-delay:.15s; }
.busy:hover { background:none; } .busy:hover { background:none; }
.busy.up { cursor:pointer; color:var(--text); } .busy.up { cursor:pointer; color:var(--text); }
.busy.up:hover { color:var(--accent); } .busy.up:hover { color:var(--accent); }
@@ -42,7 +60,9 @@
table.fm td, table.fm th { padding:7px 8px; } table.fm td, table.fm th { padding:7px 8px; }
.fm .nm { cursor:pointer; } .fm .nm { cursor:pointer; }
.fm .nm:hover { color:var(--accent); } .fm .nm:hover { color:var(--accent); }
.ic { display:inline-block; width:18px; text-align:center; margin-right:6px; } .ic { display:inline-flex; vertical-align:-3px; width:18px; justify-content:center; margin-right:6px; color:var(--muted); }
.ic.dir { color:#7aa7d9; }
.ic.dir svg { fill:rgba(122,167,217,.16); }
.sz { color:var(--muted); font-variant-numeric:tabular-nums; } .sz { color:var(--muted); font-variant-numeric:tabular-nums; }
.act { display:flex; gap:6px; justify-content:flex-end; } .act { display:flex; gap:6px; justify-content:flex-end; }
/* Sortable headers: a click sorts by the column, a second one turns it round. */ /* Sortable headers: a click sorts by the column, a second one turns it round. */
@@ -59,11 +79,15 @@
table.fm .c-act { width:1%; } table.fm .c-act { width:1%; }
table.fm td, table.fm th { padding:7px 4px; } table.fm td, table.fm th { padding:7px 4px; }
.ic { margin-right:4px; } .ic { margin-right:4px; }
.fm-bar input.fm-path { flex:1 1 100%; order:-1; } .fm-bar .fm-pathbox { flex:1 1 100%; order:-1; }
/* The selection's buttons go together on a line of their own. */
.fm-bar .spacer { flex:1 1 100%; height:0; }
} }
dialog { background:var(--panel); color:var(--text); border:1px solid var(--border); dialog { background:var(--panel); color:var(--text); border:1px solid var(--border);
border-radius:12px; padding:0; max-width:900px; width:92vw; } border-radius:12px; padding:0; max-width:900px; width:92vw; }
dialog::backdrop { background:rgba(0,0,0,.6); } /* A dialog takes the focus itself when it opens, rather than its first
button, which would light up as if tabbed to. */
dialog:focus { outline:none; }
.dlg-h { padding:14px 18px; border-bottom:1px solid var(--border); display:flex; align-items:center; gap:10px; } .dlg-h { padding:14px 18px; border-bottom:1px solid var(--border); display:flex; align-items:center; gap:10px; }
.dlg-b { padding:16px 18px; } .dlg-b { padding:16px 18px; }
#save { display:inline-flex; align-items:center; gap:8px; } #save { display:inline-flex; align-items:center; gap:8px; }
@@ -71,10 +95,92 @@
#save.saving::before { content:''; width:12px; height:12px; border:2px solid rgba(255,255,255,.35); border-top-color:#fff; #save.saving::before { content:''; width:12px; height:12px; border:2px solid rgba(255,255,255,.35); border-top-color:#fff;
border-radius:50%; animation:spin .7s linear infinite; } border-radius:50%; animation:spin .7s linear infinite; }
#save[hidden] { display:none; } #save[hidden] { display:none; }
/* Square icon buttons, the height of the buttons beside them. */
.btn.sq { width:28px; height:28px; padding:0; display:inline-flex; align-items:center; justify-content:center; }
.fm-bar .btn.sq { width:34px; }
/* Move or copy: the action, where to with suggestions, the name, and what
to do with what is already there. */
#mvdlg { max-width:560px; }
#mvdlg .dlg-b { padding:14px 18px 16px; }
.mv label { margin:12px 0 4px; }
.mv .pp-path { font-family:ui-monospace,Menlo,monospace; font-size:13px; overflow-wrap:anywhere; }
.mv .pp-act { display:flex; justify-content:flex-end; margin-top:16px; }
.mv input[type=text], .mv select { width:100%; height:36px; padding:6px 10px; }
.mv select { padding-right:36px; }
.seg { display:inline-flex; margin-top:12px; border:1px solid var(--border); border-radius:8px; overflow:hidden; }
.seg button { background:transparent; color:var(--muted); border:0; border-radius:0; padding:7px 16px; font-size:14px; }
.seg button + button { border-left:1px solid var(--border); }
.seg button.on { background:#12202f; color:var(--text); box-shadow:inset 0 -2px 0 var(--accent); }
.seg button:hover { background:#151a21; color:var(--text); }
.mv-top { display:flex; align-items:flex-end; gap:8px; }
.mv-top .mv-net { height:36px; width:36px; flex:none; color:var(--muted); transition:background .18s, color .18s, border-color .18s; }
.mv-top .mv-net:hover { color:var(--text); }
.mv-top .mv-net.on { color:var(--accent); border-color:var(--accent); background:#12202f; }
/* The other machine: slides open under the action. */
.mv-remote { display:grid; grid-template-rows:0fr; opacity:0; transition:grid-template-rows .25s ease, opacity .2s ease; }
.mv-remote > div { overflow:hidden; min-height:0; }
.mv-remote.on { grid-template-rows:1fr; opacity:1; }
.mv-remote.on > div { overflow:visible; }
.mv-ssh { display:grid; gap:8px; margin-top:10px; padding:10px 12px; border:1px solid var(--border); border-radius:9px; background:#0f1318;
animation:mv-in .2s ease; }
.mv-ssh[hidden], .mv-a[hidden] { display:none; }
@keyframes mv-in { from { opacity:0; transform:translateY(-4px); } }
.mv-ssh label { margin:0; display:grid; gap:4px; font-size:12px; color:var(--muted); }
.mv-ssh input[type=number] { width:100%; height:36px; padding:6px 10px; }
.mv-row { display:flex; gap:10px; align-items:end; }
.mv-row .grow { flex:1; min-width:0; }
.mv-row .port { width:92px; flex:none; }
.mv-ssh .seg-auth { margin-top:2px; display:flex; }
.mv-ssh .seg-auth button { flex:1; padding:6px 8px; font-size:13px; }
@media (max-width:520px) { .mv-ssh .seg-auth button { font-size:12px; padding:6px 4px; white-space:nowrap; } }
.mv-a { display:grid; gap:8px; animation:mv-in .18s ease; }
.mv-a textarea { width:100%; box-sizing:border-box; font:12px/1.4 ui-monospace,Menlo,monospace; resize:vertical; min-height:80px;
background:var(--bg); color:var(--text); border:1px solid var(--border); border-radius:8px; padding:8px 10px; }
.mv-a textarea:focus { border-color:var(--accent); outline:none; }
p.mv-a { margin:0; font-size:13px; }
#mv-keyname { font-size:12px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; align-self:center; }
.mv-prog { margin-top:14px; animation:mv-in .2s ease; }
.mv-prog[hidden] { display:none; }
.mv-bar { height:6px; border-radius:3px; background:#1a2029; overflow:hidden; }
.mv-bar i { display:block; height:100%; width:0; background:var(--accent); border-radius:3px; transition:width .5s ease; }
.mv-bar.wait i { width:30% !important; animation:mv-slide 1.1s ease-in-out infinite; }
@keyframes mv-slide { from { transform:translateX(-100%); } to { transform:translateX(340%); } }
.mv-pwrap { display:inline-flex; align-items:center; gap:8px; min-width:0; }
.mv-ptext { display:flex; align-items:center; justify-content:space-between; gap:10px; margin-top:8px; font-size:13px; color:var(--muted); }
.mv.running input, .mv.running select, .mv.running textarea, .mv.running .seg, .mv.running .mv-net { pointer-events:none; opacity:.6; }
.mv-dirbox { position:relative; }
.mv-dirbox input { font-family:ui-monospace,Menlo,monospace; font-size:13px; }
.mv-sug { position:absolute; left:0; right:0; top:calc(100% + 4px); z-index:5; max-height:220px; overflow:auto;
margin:0; padding:4px; list-style:none; background:var(--panel); border:1px solid var(--border); border-radius:8px;
box-shadow:0 8px 24px rgba(0,0,0,.45); font-family:ui-monospace,Menlo,monospace; font-size:13px; }
.mv-sug li { padding:5px 8px; border-radius:5px; cursor:pointer; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.mv-sug li.sel, .mv-sug li:hover { background:#12202f; color:var(--text); }
.mv-sug li .muted { color:var(--muted); }
/* Properties: owner, group and mode, like FileZilla's. */
#propdlg { max-width:520px; }
#propdlg .dlg-b { padding:14px 18px 16px; }
.props .pp-path { font-family:ui-monospace,Menlo,monospace; font-size:13px; overflow-wrap:anywhere; }
.props .pp-info { font-size:13px; margin:2px 0 12px; }
.props .pp-own { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
.props .pp-own label { margin:0; }
.props .pp-own input { margin-top:4px; height:36px; padding:6px 10px; }
.props table.pp-perm { margin:12px 0 4px; }
.props .pp-perm th, .props .pp-perm td { padding:4px 8px; text-align:center; font-size:13px; }
.props .pp-perm tbody th { text-align:left; color:var(--text); font-weight:400; }
.props .pp-perm input { width:16px; height:16px; cursor:pointer; }
.props .pp-num { display:flex; align-items:center; gap:10px; margin:8px 0 0; }
.props .pp-num input { width:80px; height:34px; padding:4px 10px; font-family:ui-monospace,Menlo,monospace; }
.props .pp-num input.bad { border-color:var(--danger); }
.props .pp-rec { display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between; gap:8px 16px; margin-top:12px; padding-top:12px; border-top:1px solid var(--border); }
.props .pp-rec label.check { display:inline-flex; align-items:center; gap:8px; margin:0; color:var(--text); font-size:14px; cursor:pointer; }
.props .pp-rec select { min-width:0; padding:6px 32px 6px 12px; }
.props .pp-act { display:flex; justify-content:flex-end; margin-top:14px; }
/* File viewer: the code editor, a picture / video / sound, or the hex editor. */ /* File viewer: the code editor, a picture / video / sound, or the hex editor. */
#viewer { max-width:1100px; } #viewer { max-width:1100px; }
#viewer .dlg-h { flex-wrap:wrap; } #viewer .dlg-h { flex-wrap:wrap; }
.vpath { flex:1; min-width:0; overflow:hidden; white-space:nowrap; text-overflow:ellipsis; /* Right to left so a long path is cut at its start, keeping the name in
sight; still set to the left like any title. */
.vpath { flex:1; min-width:0; overflow:hidden; white-space:nowrap; text-overflow:ellipsis; text-align:left;
font-family:ui-monospace,Menlo,monospace; font-size:13px; font-weight:600; } font-family:ui-monospace,Menlo,monospace; font-size:13px; font-weight:600; }
.vmsg { width:100%; order:9; font-size:12px; } .vmsg { width:100%; order:9; font-size:12px; }
.vbody { padding:0; } .vbody { padding:0; }
@@ -101,6 +207,53 @@
.find #frow2, .find #frow2b { grid-row:2; } .find #frow2, .find #frow2b { grid-row:2; }
/* Documents and tables, drawn in the panel's colours. */ /* Documents and tables, drawn in the panel's colours. */
.vdoc { height:75vh; overflow:auto; background:#0c0f13; border-radius:0 0 12px 12px; } .vdoc { height:75vh; overflow:auto; background:#0c0f13; border-radius:0 0 12px 12px; }
/* SQLite editor. */
.dbx { display:flex; flex-direction:column; height:100%; transition:opacity .2s; }
.dbx.busy { opacity:.75; }
.dbx-tabs { display:flex; gap:4px; padding:8px 10px 0; overflow-x:auto; flex:none; border-bottom:1px solid var(--border); }
.dbx-tabs button { flex:none; background:transparent; color:var(--muted); border:1px solid transparent; border-bottom:0; border-radius:8px 8px 0 0;
padding:6px 12px; font-size:13px; transition:background .15s, color .15s; }
.dbx-tabs button:hover { color:var(--text); background:#151a21; }
.dbx-tabs button.on { color:var(--text); background:var(--panel); border-color:var(--border); }
.dbx-tabs .dbx-sqltab { margin-left:auto; font-family:ui-monospace,Menlo,monospace; }
.dbx-tools { display:flex; align-items:center; gap:8px; padding:8px 10px; flex:none; flex-wrap:wrap; font-size:13px; }
.dbx-tools[hidden], .dbx-pager[hidden], .dbx-grid[hidden], .dbx-sql[hidden], .dbx-tools button[hidden] { display:none; }
.dbx-pager { display:inline-flex; align-items:center; gap:6px; }
.dbx-sp { flex:1; }
.dbx-grid { flex:1; min-height:0; overflow:auto; }
.dbx-t { width:auto; border-collapse:separate; border-spacing:0; font-size:13px; font-variant-numeric:tabular-nums; }
.dbx-t th, .dbx-t td { border-right:1px solid var(--border); border-bottom:1px solid var(--border); padding:5px 10px; text-align:left;
max-width:420px; white-space:pre-wrap; overflow-wrap:anywhere; vertical-align:top; }
.dbx-t thead th { position:sticky; top:0; z-index:1; background:var(--panel); color:var(--text); font-weight:500; white-space:nowrap; }
.dbx-t thead th small { display:block; color:var(--muted); font-weight:400; font-size:11px; }
.dbx-t td.num { text-align:right; }
.dbx-t .dbx-k { color:var(--muted); text-align:right; }
.dbx-t .dbx-ck { width:1%; text-align:center; }
.dbx-t td.ed { cursor:text; transition:background .15s; }
.dbx-t td.ed:hover { background:#151a21; }
.dbx-t td.editing { padding:0; background:#12202f; }
.dbx-t td.saving { opacity:.6; }
.dbx-t td.flash-ok { animation:dbx-ok 1.2s ease; }
.dbx-t td.flash-bad { animation:dbx-bad 1.2s ease; }
@keyframes dbx-ok { 0% { background:rgba(55,200,113,.28); } 100% { background:transparent; } }
@keyframes dbx-bad { 0% { background:rgba(255,92,92,.3); } 100% { background:transparent; } }
.dbx-null { color:var(--muted); font-size:12px; }
.dbx-edit { position:relative; }
.dbx-edit textarea { display:block; width:100%; min-height:30px; resize:vertical; box-sizing:border-box; margin:0; padding:5px 28px 5px 10px; border:1px solid var(--accent);
border-radius:0; background:#0c0f13; color:var(--text); font:inherit; outline:none; }
.dbx-nullb { position:absolute; top:4px; right:4px; width:20px; height:20px; padding:0; font-size:12px; line-height:1;
background:#1b222c; color:var(--muted); border:1px solid var(--border); border-radius:5px; }
.dbx-nullb:hover { color:var(--text); background:#232b36; }
.dbx-t tr.dbx-new td { padding:3px; background:#12202f; }
.dbx-t tr.dbx-new input { width:100%; min-width:90px; height:28px; padding:3px 8px; font-size:13px; }
.dbx-t tr.dbx-newbar td { background:#12202f; padding:6px 8px; white-space:nowrap; }
.dbx-sql { flex:1; min-height:0; display:flex; flex-direction:column; gap:8px; padding:10px; }
.dbx-q { flex:none; height:110px; resize:vertical; font:13px/1.5 ui-monospace,Menlo,monospace; background:#0c0f13; color:var(--text);
border:1px solid var(--border); border-radius:8px; padding:8px 10px; }
.dbx-q:focus { border-color:var(--accent); outline:none; }
.dbx-sqlbar { display:flex; align-items:center; gap:10px; font-size:13px; }
.dbx-sqlmsg.bad { color:var(--danger); }
.dbx-sqlres { flex:1; min-height:0; overflow:auto; }
.vdoc .dv-pdf { display:block; width:100%; height:100%; border:0; background:#525659; } .vdoc .dv-pdf { display:block; width:100%; height:100%; border:0; background:#525659; }
.vdoc .dv-html { display:block; width:100%; height:100%; border:0; background:#fff; } .vdoc .dv-html { display:block; width:100%; height:100%; border:0; background:#fff; }
.dv-doc { max-width:820px; margin:0 auto; padding:28px 32px 40px; font-size:15px; line-height:1.65; color:var(--text); overflow-wrap:anywhere; } .dv-doc { max-width:820px; margin:0 auto; padding:28px 32px 40px; font-size:15px; line-height:1.65; color:var(--text); overflow-wrap:anywhere; }
@@ -181,7 +334,7 @@
</head> </head>
<body> <body>
<header class="topbar"> <header class="topbar">
<div class="brand">LPRA · Файлы - {{.Agent.Name}} <span class="muted">({{.Agent.Hostname}})</span></div> <div class="brand"><a class="home" href="/" title="К списку машин">LPRA</a> · Файлы - {{.Agent.Name}} <span class="muted">({{.Agent.Hostname}})</span></div>
<nav> <nav>
<a href="/">← Машины</a> <a href="/">← Машины</a>
<a href="/agents/{{.Agent.ID}}/terminal">Терминал</a> <a href="/agents/{{.Agent.ID}}/terminal">Терминал</a>
@@ -192,16 +345,19 @@
</header> </header>
<div class="wrap" style="max-width:1100px"> <div class="wrap" style="max-width:1100px">
<div class="fm-bar"> <div class="fm-bar">
<button class="btn ghost small" id="up">↑ Вверх</button> <button class="btn ghost small ico" id="up"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5M5 12l7-7 7 7"/></svg>Вверх</button>
<div class="fm-pathbox">
<input class="fm-path" id="path" value="/" spellcheck="false" autocapitalize="off" autocomplete="off"> <input class="fm-path" id="path" value="/" spellcheck="false" autocapitalize="off" autocomplete="off">
<button class="btn ghost small icon sq" id="dirprops" title="Свойства папки" aria-label="Свойства папки"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 4h-7M10 4H3M21 12h-9M8 12H3M21 20h-5M12 20H3M14 2v4M8 10v4M16 18v4"/></svg></button>
</div>
<button type="button" class="busy" id="busy" title=""><span class="spinner" id="spin"></span><span id="uppct"></span></button> <button type="button" class="busy" id="busy" title=""><span class="spinner" id="spin"></span><span id="uppct"></span></button>
<button class="btn ghost small" id="refresh">Обновить</button> <button class="btn ghost small" id="refresh">Обновить</button>
</div> </div>
<div class="fm-bar"> <div class="fm-bar">
<button class="btn small" id="mkdir">+ Папка</button> <button class="btn small ico" id="mkdir"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.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"/><path d="M12 11v6M9 14h6"/></svg>Папка</button>
<button class="btn small" id="newfile">+ Файл</button> <button class="btn small ico" id="newfile"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6M12 12v6M9 15h6"/></svg>Файл</button>
<label class="btn small" style="cursor:pointer">+ Загрузить<input type="file" id="upl" multiple hidden></label> <label class="btn small ico" style="cursor:pointer"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12M6 10l6 6 6-6M5 21h14"/></svg>Загрузить<input type="file" id="upl" multiple hidden></label>
<span class="spacer" style="flex:1"></span> <span class="spacer"></span>
<span id="selinfo" class="muted" style="font-size:13px"></span> <span id="selinfo" class="muted" style="font-size:13px"></span>
<button class="btn ghost small" id="zipsel" disabled>В zip</button> <button class="btn ghost small" id="zipsel" disabled>В zip</button>
<button class="btn danger small" id="delsel" disabled>Удалить выбранное</button> <button class="btn danger small" id="delsel" disabled>Удалить выбранное</button>
@@ -220,12 +376,13 @@
<p class="muted" id="hint" style="font-size:13px">Перетащите файлы сюда для загрузки. Клик по файлу - просмотр и правка.</p> <p class="muted" id="hint" style="font-size:13px">Перетащите файлы сюда для загрузки. Клик по файлу - просмотр и правка.</p>
</div> </div>
<dialog id="viewer"> <dialog id="viewer" tabindex="-1">
<div class="dlg-h"> <div class="dlg-h">
<strong class="vpath" dir="rtl"><bdi id="vname"></bdi></strong> <strong class="vpath" dir="rtl"><bdi id="vname"></bdi></strong>
<button class="btn ghost small" id="vtext" hidden>Редактировать</button> <button class="btn ghost small" id="vtext" hidden>Редактировать</button>
<button class="btn ghost small" id="vhex" hidden>Редактировать бинарно</button> <button class="btn ghost small" id="vhex" hidden>Редактировать бинарно</button>
<button class="btn small" id="save" hidden>Сохранить</button> <button class="btn small" id="save" hidden>Сохранить</button>
<button class="btn ghost small icon sq" id="vprops" title="Свойства" aria-label="Свойства"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 4h-7M10 4H3M21 12h-9M8 12H3M21 20h-5M12 20H3M14 2v4M8 10v4M16 18v4"/></svg></button>
<button class="btn ghost small icon close x" id="closev" 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> <button class="btn ghost small icon close x" id="closev" 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 class="vmsg muted" id="vmsg"></div> <div class="vmsg muted" id="vmsg"></div>
</div> </div>
@@ -258,7 +415,7 @@
</div> </div>
</dialog> </dialog>
<dialog id="updlg"> <dialog id="updlg" tabindex="-1">
<div class="dlg-h"> <div class="dlg-h">
<strong style="flex:1">Загрузка файлов</strong> <strong style="flex:1">Загрузка файлов</strong>
<button class="btn ghost small icon close x" id="updlg-close" 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> <button class="btn ghost small icon close x" id="updlg-close" 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>
@@ -266,15 +423,104 @@
<div class="dlg-b" id="updlg-list"></div> <div class="dlg-b" id="updlg-list"></div>
</dialog> </dialog>
<dialog id="mvdlg" tabindex="-1">
<div class="dlg-h">
<strong style="flex:1">Переместить или копировать</strong>
<button class="btn ghost small icon close x" id="mvdlg-close" 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 mv">
<div class="pp-path" id="mv-src"></div>
<div class="mv-top">
<div class="seg" id="mv-op" role="radiogroup">
<button type="button" data-op="move" role="radio">Переместить</button>
<button type="button" data-op="copy" role="radio">Копировать</button>
</div>
<button type="button" class="btn ghost sq mv-net" id="mv-net" title="На другую машину или по SSH" aria-label="На другую машину или по SSH" aria-pressed="false"><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="2" width="6" height="6" rx="1"/><rect x="2" y="16" width="6" height="6" rx="1"/><rect x="16" y="16" width="6" height="6" rx="1"/><path d="M12 8v4M5 16v-2a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v2"/></svg></button>
</div>
<div class="mv-remote" id="mv-remote"><div>
<label for="mv-to">На машину</label>
<select id="mv-to"></select>
<div class="mv-ssh" id="mv-ssh" hidden>
<div class="mv-row"><label class="grow">Хост<input type="text" id="mv-host" placeholder="example.com" autocomplete="off" spellcheck="false" autocapitalize="off"></label>
<label class="port">Порт<input type="number" id="mv-port" min="1" max="65535" value="22"></label></div>
<label>Пользователь<input type="text" id="mv-user" placeholder="root" autocomplete="off" spellcheck="false" autocapitalize="off"></label>
<div class="seg seg-auth" id="mv-auth">
<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 class="mv-a" data-for="password"><input type="password" id="mv-pw" placeholder="пароль" autocomplete="off"></div>
<div class="mv-a" data-for="key" hidden>
<textarea id="mv-key" rows="4" spellcheck="false" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----&#10;…вставьте ключ или загрузите файл"></textarea>
<div class="mv-row"><button type="button" class="btn ghost small" id="mv-keyload">Загрузить файл…</button><span class="muted" id="mv-keyname"></span><input type="file" id="mv-keyfile" hidden></div>
<input type="password" id="mv-pass" placeholder="фраза ключа, если есть" autocomplete="off">
</div>
<p class="mv-a muted" data-for="agent" hidden>Агент войдёт своими ключами из ~/.ssh без фразы.</p>
</div>
</div></div>
<label for="mv-dir">Куда (папка)</label>
<div class="mv-dirbox">
<input type="text" id="mv-dir" autocomplete="off" spellcheck="false" autocapitalize="off">
<ul class="mv-sug" id="mv-sug" hidden></ul>
</div>
<label for="mv-name">Имя</label>
<input type="text" id="mv-name" autocomplete="off" spellcheck="false" autocapitalize="off">
<label for="mv-conflict">Если там уже есть такое</label>
<select id="mv-conflict">
<option value="error">ничего не трогать и сообщить</option>
<option value="replace">заменить (папки слить, файлы заменить)</option>
<option value="skip">оставить существующее (папки слить)</option>
</select>
<div class="mv-prog" id="mv-prog" hidden>
<div class="mv-bar"><i id="mv-bar"></i></div>
<div class="mv-ptext"><span class="mv-pwrap"><span class="spinner"></span><span id="mv-ptext">подготовка…</span></span><button type="button" class="btn ghost small" id="mv-stop">Отменить</button></div>
</div>
<div class="pp-act"><button type="button" id="mv-go">Переместить</button></div>
</div>
</dialog>
<dialog id="propdlg" tabindex="-1">
<div class="dlg-h">
<strong style="flex:1">Свойства</strong>
<button class="btn ghost small icon close x" id="propdlg-close" 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 props">
<div class="pp-path" id="pp-path"></div>
<div class="pp-info muted" id="pp-info"></div>
<div class="pp-own">
<label>Владелец<input type="text" id="pp-owner" list="pp-users" autocomplete="off" spellcheck="false"></label>
<label>Группа<input type="text" id="pp-group" list="pp-groups" autocomplete="off" spellcheck="false"></label>
</div>
<datalist id="pp-users"></datalist><datalist id="pp-groups"></datalist>
<table class="pp-perm">
<thead><tr><th></th><th>Чтение</th><th>Запись</th><th>Выполнение</th></tr></thead>
<tbody>
<tr><th>Владелец</th><td><input type="checkbox" data-bit="256"></td><td><input type="checkbox" data-bit="128"></td><td><input type="checkbox" data-bit="64"></td></tr>
<tr><th>Группа</th><td><input type="checkbox" data-bit="32"></td><td><input type="checkbox" data-bit="16"></td><td><input type="checkbox" data-bit="8"></td></tr>
<tr><th>Остальные</th><td><input type="checkbox" data-bit="4"></td><td><input type="checkbox" data-bit="2"></td><td><input type="checkbox" data-bit="1"></td></tr>
</tbody>
</table>
<label class="pp-num">Числовое значение <input type="text" id="pp-mode" maxlength="4" inputmode="numeric" autocomplete="off"></label>
<div class="pp-rec" id="pp-rec" hidden>
<label class="check"><input type="checkbox" id="pp-recursive"> Применить ко всем вложенным</label>
<select id="pp-only" disabled>
<option value="">к файлам и папкам</option>
<option value="files">только к файлам</option>
<option value="dirs">только к папкам</option>
</select>
</div>
<div class="pp-act"><button type="button" id="pp-apply">Применить</button></div>
</div>
</dialog>
<div class="toast" id="toast"></div> <div class="toast" id="toast"></div>
<script src="/static/codemirror/codemirror.js"></script> <script src="{{asset "codemirror/codemirror.js"}}"></script>
<script src="/static/docview.js"></script> <script src="{{asset "docview.js"}}"></script>
<script src="{{asset "dbedit.js"}}"></script>
<script> <script>
(function () { (function () {
const agentID = {{.Agent.ID}}; const agentID = {{.Agent.ID}};
let busyN = 0; let busyN = 0;
function setBusy(on) { busyN += on ? 1 : -1; if (busyN < 0) busyN = 0; document.getElementById('spin').style.display = busyN > 0 ? 'inline-block' : 'none'; } function setBusy(on) { busyN += on ? 1 : -1; if (busyN < 0) busyN = 0; document.getElementById('busy').classList.toggle('on', busyN > 0); }
const api = (op, body) => { const api = (op, body) => {
setBusy(true); setBusy(true);
return fetch('/api/panel/agents/' + encodeURIComponent(agentID) + '/fs/' + op, { return fetch('/api/panel/agents/' + encodeURIComponent(agentID) + '/fs/' + op, {
@@ -290,6 +536,30 @@
const ICON_MV = SVG + '<polyline points="5 9 2 12 5 15"/><polyline points="9 5 12 2 15 5"/><polyline points="15 19 12 22 9 19"/><polyline points="19 9 22 12 19 15"/><line x1="2" y1="12" x2="22" y2="12"/><line x1="12" y1="2" x2="12" y2="22"/></svg>'; const ICON_MV = SVG + '<polyline points="5 9 2 12 5 15"/><polyline points="9 5 12 2 15 5"/><polyline points="15 19 12 22 9 19"/><polyline points="19 9 22 12 19 15"/><line x1="2" y1="12" x2="22" y2="12"/><line x1="12" y1="2" x2="12" y2="22"/></svg>';
const ICON_UNZIP = SVG + '<rect x="2" y="3" width="20" height="5" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h2"/><path d="M20 8v11a2 2 0 0 1-2 2h-2"/><path d="m9 15 3-3 3 3"/><path d="M12 12v9"/></svg>'; const ICON_UNZIP = SVG + '<rect x="2" y="3" width="20" height="5" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h2"/><path d="M20 8v11a2 2 0 0 1-2 2h-2"/><path d="m9 15 3-3 3 3"/><path d="M12 12v9"/></svg>';
const ICON_DEL = SVG + '<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>'; const ICON_DEL = SVG + '<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>';
// File icons: one colour, the line style of the buttons; a few kinds by
// the name's ending.
const FI = '<svg class="fi" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">';
const FICONS = {
dir: FI + '<path d="M3 7a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2V17a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>',
file: FI + '<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/></svg>',
text: FI + '<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5M9 13h6M9 17h4"/></svg>',
code: FI + '<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5M10 12l-2 2.5 2 2.5M14 12l2 2.5-2 2.5"/></svg>',
img: FI + '<rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="9" cy="10" r="1.6"/><path d="m21 16-5-5-8 9"/></svg>',
arc: FI + '<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5M11 4v2M11 8v2M11 12v2"/><rect x="9.5" y="15" width="3" height="3" rx=".6"/></svg>',
media:FI + '<rect x="3" y="5" width="18" height="14" rx="2"/><path d="m10 9 5 3-5 3z"/></svg>',
link: FI + '<path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1"/><path d="M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1"/></svg>'
};
function fileIcon(e) {
let k = 'file';
if (e.is_dir) k = 'dir';
else if (/^L/.test(e.mode || '')) k = 'link';
else if (/\.(png|jpe?g|gif|webp|bmp|ico|svg|avif|heic)$/i.test(e.name)) k = 'img';
else if (/\.(zip|tar|gz|tgz|bz2|xz|zst|7z|rar|deb|rpm)$/i.test(e.name)) k = 'arc';
else if (/\.(mp4|m4v|webm|mov|mkv|avi|mp3|wav|ogg|flac|m4a|aac|opus)$/i.test(e.name)) k = 'media';
else if (/\.(js|ts|go|py|rb|rs|c|h|cpp|java|kt|php|sh|bash|zsh|json|ya?ml|toml|html?|css|sql|lua|pl|swift|vue|jsx|tsx|ini|conf|xml)$/i.test(e.name)) k = 'code';
else if (/\.(txt|md|log|csv|rst|cfg|env)$|^(readme|license|makefile|dockerfile)/i.test(e.name)) k = 'text';
return '<span class="ic' + (k === 'dir' ? ' dir' : '') + '">' + FICONS[k] + '</span>';
}
let cwd = '/'; let cwd = '/';
let entries = []; let entries = [];
const selected = new Set(); const selected = new Set();
@@ -298,8 +568,10 @@
// An open dialog sits in the page's top layer, above any z-index: the toast // An open dialog sits in the page's top layer, above any z-index: the toast
// goes inside it then, so "сохранено" or an error shows over the editor. // goes inside it then, so "сохранено" or an error shows over the editor.
let toastTimer = null; let toastTimer = null;
// Opens a dialog with the focus on the dialog itself, not its first button.
const openModal = d => { d.showModal(); d.focus(); };
const toast = (msg, err) => { const toast = (msg, err) => {
const t = $('toast'), host = document.querySelector('dialog[open]') || document.body; const t = $('toast'), host = [...document.querySelectorAll('dialog[open]')].pop() || document.body;
if (t.parentNode !== host) host.appendChild(t); if (t.parentNode !== host) host.appendChild(t);
t.textContent = msg; t.textContent = msg;
t.className = 'toast show' + (err ? ' err' : ''); t.className = 'toast show' + (err ? ' err' : '');
@@ -370,13 +642,13 @@
const dlHref = '/api/panel/agents/' + encodeURIComponent(agentID) + '/fs/download?path=' + encodeURIComponent(join(cwd, e.name)); const dlHref = '/api/panel/agents/' + encodeURIComponent(agentID) + '/fs/download?path=' + encodeURIComponent(join(cwd, e.name));
tr.innerHTML = tr.innerHTML =
'<td><input type="checkbox" class="ck" data-n="'+encodeURIComponent(e.name)+'"></td>' + '<td><input type="checkbox" class="ck" data-n="'+encodeURIComponent(e.name)+'"></td>' +
'<td class="nm"><span class="ic">'+(e.is_dir?'📁':'📄')+'</span>'+esc(e.name)+'</td>' + '<td class="nm">'+fileIcon(e)+esc(e.name)+'</td>' +
'<td class="sz c-sz">'+(e.is_dir?'':fmtSize(e.size))+'</td>' + '<td class="sz c-sz">'+(e.is_dir?'':fmtSize(e.size))+'</td>' +
'<td class="sz mt">'+fmtTime(e.mtime)+'</td>' + '<td class="sz mt">'+fmtTime(e.mtime)+'</td>' +
'<td><div class="act">' + '<td><div class="act">' +
(isZip?'<button class="btn ghost small icon" data-act="unzip" title="Распаковать сюда">'+ICON_UNZIP+'</button>':'') + (isZip?'<button class="btn ghost small icon" data-act="unzip" title="Распаковать сюда">'+ICON_UNZIP+'</button>':'') +
(e.is_dir?'':'<a class="btn ghost small icon" title="Скачать" href="'+dlHref+'" download>'+ICON_DL+'</a>') + (e.is_dir?'':'<a class="btn ghost small icon" title="Скачать" href="'+dlHref+'" download>'+ICON_DL+'</a>') +
'<button class="btn ghost small icon" data-act="ren" title="Переместить / переименовать">'+ICON_MV+'</button>' + '<button class="btn ghost small icon" data-act="ren" title="Переместить или копировать">'+ICON_MV+'</button>' +
'<button class="btn danger small icon" data-act="del" title="Удалить">'+ICON_DEL+'</button>' + '<button class="btn danger small icon" data-act="del" title="Удалить">'+ICON_DEL+'</button>' +
'</div></td>'; '</div></td>';
const nm = tr.querySelector('.nm'); const nm = tr.querySelector('.nm');
@@ -536,7 +808,7 @@
V.path = path; V.dirty = false; V.path = path; V.dirty = false;
$('vname').textContent = path; $('vname').textContent = path;
$('vname').parentElement.title = path; $('vname').parentElement.title = path;
if (!$('viewer').open) $('viewer').showModal(); if (!$('viewer').open) openModal($('viewer'));
} }
async function view(e) { async function view(e) {
@@ -641,6 +913,19 @@
if (r.data.truncated) $('vmsg').textContent += ' · показаны первые ' + fmtSize(EDIT_LIMIT); if (r.data.truncated) $('vmsg').textContent += ' · показаны первые ' + fmtSize(EDIT_LIMIT);
return; return;
} }
if (/^(db|sqlite3?|db3)$/.test(ext)) {
// The agent opens the database and edits it; one that predates that
// gets the file read here, for viewing only.
box.innerHTML = '<p class="dv-note">загрузка…</p>';
const r = await DBEdit.open(box, path, (op, args) => api(op, args), toast);
if (V.path !== path || V.kind !== 'doc') return;
if (r && r.ok) return;
if (!/неизвестная операция/.test((r && r.error) || '')) {
box.innerHTML = '<p class="dv-note">не удалось открыть: ' + esc((r && r.error) || 'ошибка') + '</p>';
return;
}
$('vmsg').append(' · только просмотр: обновите агента, чтобы править');
}
if (size > DOC_CAP) { box.innerHTML = '<p class="dv-note">файл больше ' + fmtSize(DOC_CAP) + ' - для просмотра скачайте его</p>'; return; } if (size > DOC_CAP) { box.innerHTML = '<p class="dv-note">файл больше ' + fmtSize(DOC_CAP) + ' - для просмотра скачайте его</p>'; return; }
box.innerHTML = '<p class="dv-note">загрузка…</p>'; box.innerHTML = '<p class="dv-note">загрузка…</p>';
let html; let html;
@@ -921,6 +1206,78 @@
$('viewer').addEventListener('cancel', ev => { ev.preventDefault(); if (F.open) closeFind(); else closeViewer(); }); $('viewer').addEventListener('cancel', ev => { ev.preventDefault(); if (F.open) closeFind(); else closeViewer(); });
$('closev').onclick = closeViewer; $('closev').onclick = closeViewer;
// ---------- Properties ----------
// Owner, group and mode of a file or folder; a folder's can go to all that
// is in it too.
const P = { path: '', orig: null };
const modeStr = m => m.toString(8).padStart(m > 0o777 ? 4 : 3, '0');
const permBoxes = () => [...document.querySelectorAll('#propdlg .pp-perm input[data-bit]')];
function showMode(m) {
permBoxes().forEach(b => b.checked = !!(m & +b.dataset.bit));
$('pp-mode').value = modeStr(m);
$('pp-mode').classList.remove('bad');
}
function readMode() {
const v = $('pp-mode').value.trim();
return /^[0-7]{3,4}$/.test(v) ? parseInt(v, 8) : null;
}
permBoxes().forEach(b => b.onchange = () => {
let m = (readMode() ?? P.orig.mode) & 0o7000;
permBoxes().forEach(x => { if (x.checked) m |= +x.dataset.bit; });
showMode(m);
});
$('pp-mode').oninput = () => {
const m = readMode();
$('pp-mode').classList.toggle('bad', m === null);
if (m !== null) permBoxes().forEach(b => b.checked = !!(m & +b.dataset.bit));
};
$('pp-recursive').onchange = () => $('pp-only').disabled = !$('pp-recursive').checked;
async function openProps(path) {
const r = await api('stat', {path});
if (!r.ok) {
toast(/неизвестная операция/.test(r.error || '') ? 'свойства недоступны: обновите клиента' : (r.error || 'ошибка'), true);
return;
}
const d = r.data;
P.path = path; P.orig = d;
$('pp-path').textContent = path;
$('pp-info').textContent = (d.is_dir ? 'папка' : 'файл · ' + fmtSize(d.size)) + ' · изменён ' + fmtTime(d.mtime) +
(d.link ? ' · ссылка на ' + d.link : '');
$('pp-owner').value = d.owner; $('pp-group').value = d.group;
$('pp-users').innerHTML = (d.users || []).map(u => '<option value="' + esc(u) + '">').join('');
$('pp-groups').innerHTML = (d.groups || []).map(g => '<option value="' + esc(g) + '">').join('');
showMode(d.mode);
$('pp-rec').hidden = !d.is_dir;
$('pp-recursive').checked = false; $('pp-only').value = ''; $('pp-only').disabled = true;
if (!$('propdlg').open) openModal($('propdlg'));
}
$('pp-apply').onclick = async () => {
const o = P.orig, mode = readMode();
if (mode === null) { toast('права - три или четыре восьмеричные цифры, например 755', true); return; }
const rec = o.is_dir && $('pp-recursive').checked;
const owner = $('pp-owner').value.trim(), group = $('pp-group').value.trim();
const args = {path: P.path};
if (mode !== o.mode || rec) args.mode = mode;
if (owner && (owner !== o.owner || rec)) args.owner = owner;
if (group && (group !== o.group || rec)) args.group = group;
if (rec) { args.recursive = true; args.only = $('pp-only').value; }
if (Object.keys(args).length === 1) { toast('ничего не изменено'); return; }
if (rec && !confirm('Применить к «' + P.path + '» и всему, что внутри?')) return;
const btn = $('pp-apply');
btn.disabled = true;
const r = await api('chmod', args);
btn.disabled = false;
if (!r.ok) { toast(/неизвестная операция/.test(r.error || '') ? 'обновите клиента' : (r.error || 'ошибка'), true); return; }
const c = r.data || {};
toast(rec ? 'применено: ' + c.changed + (c.failed ? ', не удалось: ' + c.failed + ' (' + (c.errors || [])[0] + ')' : '') : 'применено', !!c.failed);
openProps(P.path);
load(cwd);
};
$('propdlg-close').onclick = () => $('propdlg').close();
$('vprops').onclick = () => { if (V.path) openProps(V.path); };
$('dirprops').onclick = () => openProps(cwd);
async function del(e) { async function del(e) {
if (e.is_dir) { if (e.is_dir) {
if (!confirm('Рекурсивно удалить каталог "'+e.name+'" со всем содержимым?')) return; if (!confirm('Рекурсивно удалить каталог "'+e.name+'" со всем содержимым?')) return;
@@ -933,12 +1290,250 @@
} }
} }
async function move(e) { // ---------- Move or copy ----------
const dst = prompt('Переместить / переименовать в (полный путь):', join(cwd, e.name)); // remote: to another machine (to: its id) or an SSH host (to: 'ssh').
if (!dst) return; const M = { src: '', op: 'move', sug: [], sel: -1, seq: 0, remote: false, to: '', auth: 'password', job: null };
const r = await api('move', {src: join(cwd, e.name), dst}); const dirCache = new Map(); // machine + folder -> {at, names} of its subfolders
r.ok ? (toast('перемещено'), load(cwd)) : toast(r.error||'ошибка', true); // The machine the folder is on: this one, or the one it goes to.
const target = () => !M.remote ? agentID : M.to === 'ssh' ? '' : M.to;
// Lists a folder for the suggestions, quietly: no spinner, kept a little.
async function subdirs(path) {
const on = target();
if (!on) return [];
const key = on + '\n' + path;
const c = dirCache.get(key);
if (c && Date.now() - c.at < 30000) return c.names;
try {
const r = await fetch('/api/panel/agents/' + encodeURIComponent(on) + '/fs/list', {
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({path})
}).then(r => r.json());
const names = r.ok ? (r.data.entries || []).filter(e => e.is_dir).map(e => e.name).sort(byNameStr) : [];
dirCache.set(key, {at: Date.now(), names});
return names;
} catch (e) { return []; }
} }
const byNameStr = (a, b) => a.localeCompare(b, undefined, {numeric: true, sensitivity: 'base'});
function setOp(op) {
M.op = op;
document.querySelectorAll('#mv-op button').forEach(b => { b.classList.toggle('on', b.dataset.op === op); b.setAttribute('aria-checked', b.dataset.op === op); });
goLabel();
}
function goLabel() {
$('mv-go').textContent = (M.op === 'move' ? 'Переместить' : 'Копировать') + (M.remote ? ' туда' : '');
}
// ---- to another machine, or by SSH ----
function lastShip() { try { return JSON.parse(localStorage.getItem('lpra.ship.last') || '{}'); } catch (e) { return {}; } }
function setRemote(on) {
M.remote = on;
$('mv-net').classList.toggle('on', on);
$('mv-net').setAttribute('aria-pressed', on);
$('mv-remote').classList.toggle('on', on);
goLabel();
if (on) loadTargets();
$('mv-sug').hidden = true;
}
function setTo(v) {
M.to = v;
$('mv-ssh').hidden = v !== 'ssh';
dirCache.clear();
}
function setAuth(a) {
M.auth = a;
document.querySelectorAll('#mv-auth button').forEach(b => b.classList.toggle('on', b.dataset.auth === a));
document.querySelectorAll('.mv-a').forEach(el => { el.hidden = el.dataset.for !== a; });
}
async function loadTargets() {
const sel = $('mv-to'), last = lastShip();
sel.innerHTML = '<option value="" disabled>загрузка…</option>';
let all = [];
try { all = await fetch('/api/panel/agents/presence', {cache: 'no-store'}).then(r => r.json()); } catch (e) { /* SSH only */ }
sel.innerHTML = '';
all.filter(a => a.id !== agentID).forEach(a => {
const o = document.createElement('option');
o.value = a.id; o.disabled = !a.online;
o.textContent = a.name + ' · ' + a.user + '@' + a.hostname + (a.online ? '' : ' (не в сети)');
sel.appendChild(o);
});
const o = document.createElement('option');
o.value = 'ssh'; o.textContent = 'SSH-хост…';
sel.appendChild(o);
const ok = v => [...sel.options].some(x => x.value === v && !x.disabled);
const first = [...sel.options].find(x => !x.disabled);
sel.value = ok(last.to) ? last.to : first ? first.value : 'ssh';
setTo(sel.value);
if (last.ssh) { $('mv-host').value = last.ssh.host || ''; $('mv-port').value = last.ssh.port || 22; $('mv-user').value = last.ssh.user || ''; setAuth(last.ssh.auth || 'password'); }
}
$('mv-net').onclick = () => { if (!M.job) setRemote(!M.remote); };
$('mv-to').onchange = ev => { setTo(ev.target.value); suggest(); };
document.querySelectorAll('#mv-auth button').forEach(b => b.onclick = () => setAuth(b.dataset.auth));
$('mv-keyload').onclick = () => $('mv-keyfile').click();
$('mv-keyfile').onchange = () => {
const f = $('mv-keyfile').files[0];
if (!f) return;
if (f.size > 32768) { toast('файл великоват для ключа', true); return; }
f.text().then(t => { $('mv-key').value = t.trim() + '\n'; $('mv-keyname').textContent = f.name; });
$('mv-keyfile').value = '';
};
function sshTarget() {
const t = {host: $('mv-host').value.trim(), port: Number($('mv-port').value) || 22, user: $('mv-user').value.trim()};
if (!t.host || !t.user) return {error: 'укажите хост и пользователя'};
if (M.auth === 'password') {
if (!$('mv-pw').value) return {error: 'введите пароль'};
t.password = $('mv-pw').value;
} else if (M.auth === 'key') {
if (!/PRIVATE KEY/.test($('mv-key').value)) return {error: 'вставьте закрытый ключ или загрузите файл'};
t.key = $('mv-key').value;
if ($('mv-pass').value) t.passphrase = $('mv-pass').value;
}
return {t};
}
function busy(on) {
document.querySelector('#mvdlg .mv').classList.toggle('running', on);
$('mv-go').disabled = on;
$('mv-prog').hidden = !on;
if (on) requestAnimationFrame(() => $('mv-prog').scrollIntoView({block: 'nearest', behavior: 'smooth'}));
}
// ship sends to the other machine and follows the job to its end.
async function ship(dir, name, conflict, move) {
const body = {src: M.src, name, dir, conflict, move};
const keep = {to: M.to};
if (M.to === 'ssh') {
const r = sshTarget();
if (r.error) { toast(r.error, true); return; }
body.ssh = r.t;
keep.ssh = {host: r.t.host, port: r.t.port, user: r.t.user, auth: M.auth};
} else {
if (!M.to) { toast('выберите машину', true); return; }
body.to = M.to;
keep.ssh = lastShip().ssh;
}
try { localStorage.setItem('lpra.ship.last', JSON.stringify(keep)); } catch (e) { /* not kept */ }
busy(true);
$('mv-bar').parentNode.classList.add('wait');
$('mv-bar').style.width = '0';
$('mv-ptext').textContent = 'подготовка…';
const r = await api('ship', body);
$('mv-pw').value = ''; $('mv-pass').value = '';
if (!r.ok) { busy(false); toast(r.error || 'ошибка', true); return; }
const job = M.job = r.data.job;
let v = 0, res = null;
const t0 = Date.now(), seen = [];
while (!res) {
let j;
try { j = await fetch('/api/panel/fs/job/' + job + '?v=' + v, {cache: 'no-store'}).then(x => x.json()); }
catch (e) { await new Promise(ok => setTimeout(ok, 1500)); continue; }
v = j.v || 0;
const p = j.progress || {};
if (p.total > 0 || p.done > 0) {
$('mv-bar').parentNode.classList.remove('wait');
$('mv-bar').style.width = (p.total ? Math.min(100, p.done / p.total * 100) : 0).toFixed(1) + '%';
// Speed over the last few seconds.
seen.push([Date.now(), p.done]);
while (seen.length > 2 && Date.now() - seen[0][0] > 4000) seen.shift();
const dt = (seen[seen.length - 1][0] - seen[0][0]) / 1000, rate = dt > 0.5 ? (seen[seen.length - 1][1] - seen[0][1]) / dt : 0;
$('mv-ptext').textContent = fmtSize(p.done) + ' из ' + fmtSize(p.total) + ' · файлов: ' + p.files + (rate ? ' · ' + fmtSize(Math.round(rate)) + '/с' : '');
} else if (p.note) {
$('mv-ptext').textContent = p.note === 'подсчёт' ? 'подсчёт…' : p.note + '…';
}
if (j.done) res = j.result;
}
M.job = null;
if (!res.ok) { busy(false); toast(res.error || 'ошибка', true); return; }
$('mv-bar').style.width = '100%';
const d = res.data || {}, secs = Math.max(1, Math.round((Date.now() - t0) / 1000));
const what = move ? 'перенесено' : 'отправлено';
toast(what + (d.done != null ? ': ' + d.done : d.files != null ? ': ' + d.files : '') + (d.skipped ? ', оставлено как было: ' + d.skipped : '') + ' · ' + secs + ' с');
setTimeout(() => { busy(false); if ($('mvdlg').open) $('mvdlg').close(); }, 400);
if (move) load(cwd);
}
$('mv-stop').onclick = () => {
if (M.job) fetch('/api/panel/fs/job/' + M.job + '/cancel', {method: 'POST'});
$('mv-ptext').textContent = 'отмена…';
};
document.querySelectorAll('#mv-op button').forEach(b => b.onclick = () => setOp(b.dataset.op));
// Suggestions: the subfolders of what is typed, matching its last part.
async function suggest() {
const v = $('mv-dir').value, seq = ++M.seq;
const i = v.lastIndexOf('/');
const parent = i <= 0 ? '/' : v.slice(0, i), pre = v.slice(i + 1).toLowerCase();
const names = await subdirs(parent);
if (seq !== M.seq) return;
M.sug = names.filter(n => n.toLowerCase().startsWith(pre)).slice(0, 60)
.map(n => (parent === '/' ? '' : parent) + '/' + n + '/');
M.sel = -1;
showSug();
}
function showSug() {
const ul = $('mv-sug');
ul.innerHTML = '';
M.sug.forEach((full, k) => {
const li = document.createElement('li');
const cut = full.slice(0, -1).lastIndexOf('/') + 1;
li.innerHTML = '<span class="muted"></span><span></span>';
li.firstChild.textContent = full.slice(0, cut);
li.lastChild.textContent = full.slice(cut);
if (k === M.sel) li.className = 'sel';
li.onmousedown = ev => { ev.preventDefault(); pick(k); };
ul.appendChild(li);
});
ul.hidden = !M.sug.length || document.activeElement !== $('mv-dir');
const sel = ul.children[M.sel];
if (sel) sel.scrollIntoView({block: 'nearest'});
}
function pick(k) {
$('mv-dir').value = M.sug[k];
suggest();
}
let sugTimer = null;
$('mv-dir').addEventListener('input', () => { clearTimeout(sugTimer); sugTimer = setTimeout(suggest, 120); });
$('mv-dir').addEventListener('focus', suggest);
$('mv-dir').addEventListener('blur', () => setTimeout(() => { $('mv-sug').hidden = true; }, 100));
$('mv-dir').addEventListener('keydown', ev => {
const open = !$('mv-sug').hidden && M.sug.length;
if (ev.key === 'ArrowDown' && open) { ev.preventDefault(); M.sel = (M.sel + 1) % M.sug.length; showSug(); }
else if (ev.key === 'ArrowUp' && open) { ev.preventDefault(); M.sel = (M.sel - 1 + M.sug.length) % M.sug.length; showSug(); }
else if ((ev.key === 'Tab' || ev.key === 'Enter') && open && (M.sel >= 0 || ev.key === 'Tab')) { ev.preventDefault(); pick(Math.max(0, M.sel)); }
else if (ev.key === 'Enter') { ev.preventDefault(); $('mv-go').click(); }
else if (ev.key === 'Escape' && open) { ev.preventDefault(); ev.stopPropagation(); $('mv-sug').hidden = true; }
});
$('mv-name').addEventListener('keydown', ev => { if (ev.key === 'Enter') { ev.preventDefault(); $('mv-go').click(); } });
function move(e) {
M.src = join(cwd, e.name);
$('mv-src').textContent = M.src + (e.is_dir ? '/' : '');
setOp('move');
if (!M.job) { busy(false); setRemote(false); }
$('mv-dir').value = cwd === '/' ? '/' : cwd + '/';
$('mv-name').value = e.name;
$('mv-conflict').value = 'error';
dirCache.clear();
openModal($('mvdlg'));
$('mv-dir').focus();
const end = $('mv-dir').value.length;
$('mv-dir').setSelectionRange(end, end);
}
$('mv-go').onclick = async () => {
const dir = $('mv-dir').value.trim().replace(/\/+$/, '') || '/';
const name = $('mv-name').value.trim();
if (!name || name.includes('/')) { toast('имя - без «/»', true); return; }
const dst = (dir === '/' ? '' : dir) + '/' + name;
const conflict = $('mv-conflict').value, move = M.op === 'move';
if (M.remote) { if (!M.job) ship(dir, name, conflict, move); return; }
const btn = $('mv-go');
btn.disabled = true;
let r = await api('copy', {src: M.src, dst, move, conflict});
// A client that predates copying still moves, when nothing is in the way.
if (!r.ok && /неизвестная операция/.test(r.error || '')) {
r = move && conflict === 'error' ? await api('move', {src: M.src, dst}) : {ok: false, error: 'копирование и замена: обновите клиента'};
}
btn.disabled = false;
if (!r.ok) { toast(r.error || 'ошибка', true); return; }
const skipped = r.data && r.data.skipped;
toast((move ? 'перемещено' : 'скопировано') + (skipped ? ', оставлено как было: ' + skipped : ''));
$('mvdlg').close();
load(cwd);
};
$('mvdlg-close').onclick = () => $('mvdlg').close();
async function unzip(e) { async function unzip(e) {
const r = await api('unzip', {path: join(cwd, e.name), dest: cwd}); const r = await api('unzip', {path: join(cwd, e.name), dest: cwd});
@@ -994,7 +1589,10 @@
const busy = $('busy'); const busy = $('busy');
busy.classList.toggle('up', uploads.length > 0); busy.classList.toggle('up', uploads.length > 0);
busy.title = uploads.length ? 'Показать загружаемые файлы' : ''; busy.title = uploads.length ? 'Показать загружаемые файлы' : '';
$('uppct').textContent = pend.length ? Math.floor(100 * sent / total) + '%' + (counted.length > 1 ? ' · ' + (counted.length - pend.length) + '/' + counted.length : '') : ''; // The figures slide in while files go and slide out after, keeping the
// last text as they go.
if (pend.length) $('uppct').textContent = Math.floor(100 * sent / total) + '%' + (counted.length > 1 ? ' · ' + (counted.length - pend.length) + '/' + counted.length : '');
$('uppct').classList.toggle('on', pend.length > 0);
const list = $('updlg-list'); const list = $('updlg-list');
if (!$('updlg').open) return; if (!$('updlg').open) return;
list.innerHTML = uploads.length ? '' : '<p class="muted">Сейчас ничего не загружается.</p>'; list.innerHTML = uploads.length ? '' : '<p class="muted">Сейчас ничего не загружается.</p>';
@@ -1013,7 +1611,7 @@
list.appendChild(el); list.appendChild(el);
} }
} }
$('busy').onclick = () => { if (!uploads.length) return; $('updlg').showModal(); renderUploads(); }; $('busy').onclick = () => { if (!uploads.length) return; openModal($('updlg')); renderUploads(); };
$('updlg-close').onclick = () => $('updlg').close(); $('updlg-close').onclick = () => $('updlg').close();
// The pieces go on to the client as they come, so the bar follows the // The pieces go on to the client as they come, so the bar follows the
+4 -2
View File
@@ -4,7 +4,9 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Порты · {{.Agent.Name}} - LPRA</title> <title>Порты · {{.Agent.Name}} - LPRA</title>
<link rel="stylesheet" href="/static/app.css"> <link rel="stylesheet" href="{{asset "app.css"}}">
<script src="{{asset "pin.js"}}"></script>
<script src="{{asset "scroll.js"}}" defer></script>
<style> <style>
/* One line: fixed narrow fields, the label takes what is left. */ /* One line: fixed narrow fields, the label takes what is left. */
.frow { display:flex; align-items:flex-end; gap:12px; flex-wrap:wrap; } .frow { display:flex; align-items:flex-end; gap:12px; flex-wrap:wrap; }
@@ -37,7 +39,7 @@
</head> </head>
<body> <body>
<header class="topbar"> <header class="topbar">
<div class="brand">LPRA · Порты - {{.Agent.Name}} <span class="muted">({{.Agent.Hostname}})</span></div> <div class="brand"><a class="home" href="/" title="К списку машин">LPRA</a> · Порты - {{.Agent.Name}} <span class="muted">({{.Agent.Hostname}})</span></div>
<nav> <nav>
<a href="/">← Машины</a> <a href="/">← Машины</a>
<a href="/agents/{{.Agent.ID}}/terminal">Терминал</a> <a href="/agents/{{.Agent.ID}}/terminal">Терминал</a>
+53 -7
View File
@@ -4,21 +4,67 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Вход - LPRA</title> <title>Вход - LPRA</title>
<link rel="stylesheet" href="/static/app.css"> <link rel="stylesheet" href="{{asset "app.css"}}">
</head> </head>
<body> <body>
<div class="center"> <div class="center">
<div class="card"> <div class="card">
<h1>Вход в панель</h1> <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"> <form method="post" action="/login">
<label>Имя пользователя</label> <label for="u">Имя пользователя</label>
<input type="text" name="username" autofocus required> <input type="text" id="u" name="username" value="{{.Username}}" autocomplete="username webauthn" autofocus required>
<label>Пароль</label> <label for="pw">Пароль</label>
<input type="password" name="password" required> <input type="password" id="pw" name="password" autocomplete="current-password" required>
<div style="margin-top:18px"><button type="submit">Войти</button></div> <div style="margin-top:18px"><button type="submit" style="width:100%">Войти</button></div>
</form> </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>
</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> </body>
</html> </html>
+155 -23
View File
@@ -4,17 +4,58 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Настройки - LPRA</title> <title>Настройки - LPRA</title>
<link rel="stylesheet" href="/static/app.css"> <link rel="stylesheet" href="{{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> </head>
<body> <body>
<header class="topbar"> <header class="topbar">
<div class="brand">LPRA · Удалённый доступ</div> <div class="brand"><a class="home" href="/" title="К списку машин">LPRA</a> · Удалённый доступ</div>
<nav> <nav>
<a href="/">Машины</a> <a href="/">Машины</a>
<a href="/settings">Настройки</a> <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>
<form method="post" action="/logout" style="display:inline; margin-left:22px">
<button class="btn ghost small" type="submit">Выйти</button>
</form>
</nav> </nav>
</header> </header>
<div class="wrap"> <div class="wrap">
@@ -27,14 +68,29 @@
поставит сервис и зарегистрирует машину.</p> поставит сервис и зарегистрирует машину.</p>
<label>Короткая команда (секрет уже вшит в ссылку)</label> <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"> <details style="margin-top:10px">
<summary class="muted" style="cursor:pointer">Полные варианты (секрет в команде)</summary> <summary class="muted" style="cursor:pointer">Полные варианты (секрет в команде)</summary>
<label>curl</label> <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> <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">Без переменных окружения скрипт спросит секрет интерактивно: <p class="muted">Без переменных окружения скрипт спросит секрет интерактивно:
<code>curl -fsSL {{.Host}}/install.sh | sudo sh</code></p> <code>curl -fsSL {{.Host}}/install.sh | sudo sh</code></p>
</details> </details>
@@ -42,11 +98,18 @@
<div class="card"> <div class="card">
<h2>Адрес хоста</h2> <h2>Адрес хоста</h2>
<p class="muted">URL, на который звонят агенты. Подставляется в скрипт установки.</p> <form method="post" action="/settings/host" class="hostform">
<form method="post" action="/settings/host" class="row">
<input type="hidden" name="csrf" value="{{.CSRF}}"> <input type="hidden" name="csrf" value="{{.CSRF}}">
<input type="text" name="host" value="{{.Host}}" style="max-width:420px"> <label for="host">Основной</label>
<button type="submit">Сохранить</button> <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> </form>
</div> </div>
@@ -84,15 +147,38 @@
</div> </div>
<div class="card"> <div class="card">
<h2>Администраторы</h2> <h2 id="users">Пользователи</h2>
<table> <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> <tbody>
{{range .Admins}} {{range .Admins}}
<tr> <tr data-id="{{.ID}}" data-name="{{.Username}}">
<td>{{.Username}}</td> <td>{{.Username}}{{if eq .ID $.Me.ID}} <span class="muted">(вы)</span>{{end}}</td>
<td style="text-align:right"> <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" <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="csrf" value="{{$.CSRF}}">
<input type="hidden" name="id" value="{{.ID}}"> <input type="hidden" name="id" value="{{.ID}}">
<button class="btn danger small" type="submit">Удалить</button> <button class="btn danger small" type="submit">Удалить</button>
@@ -101,17 +187,35 @@
</tr> </tr>
{{end}} {{end}}
</tbody> </tbody>
</table> </table></div>
<form method="post" action="/settings/admins/add" style="margin-top:14px"> <form method="post" action="/settings/admins/add" class="u-add">
<input type="hidden" name="csrf" value="{{.CSRF}}"> <input type="hidden" name="csrf" value="{{.CSRF}}">
<div class="row"> <div class="row">
<input type="text" name="username" placeholder="логин" 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> <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> <button type="submit">Добавить</button>
</div> </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> </form>
</div> </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"> <div class="card">
<h2>Firewall (ufw)</h2> <h2>Firewall (ufw)</h2>
{{if not .UfwInstalled}} {{if not .UfwInstalled}}
@@ -176,5 +280,33 @@
<p class="muted" style="font-size:12px; text-align:center">LPRA сервер · версия {{.ServerVersion}}</p> <p class="muted" style="font-size:12px; text-align:center">LPRA сервер · версия {{.ServerVersion}}</p>
</div> </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> </body>
</html> </html>
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Настройка - LPRA</title> <title>Настройка - LPRA</title>
<link rel="stylesheet" href="/static/app.css"> <link rel="stylesheet" href="{{asset "app.css"}}">
</head> </head>
<body> <body>
<div class="center"> <div class="center">
+4 -2
View File
@@ -4,7 +4,9 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Статус · {{.Agent.Name}} - LPRA</title> <title>Статус · {{.Agent.Name}} - LPRA</title>
<link rel="stylesheet" href="/static/app.css"> <link rel="stylesheet" href="{{asset "app.css"}}">
<script src="{{asset "pin.js"}}"></script>
<script src="{{asset "scroll.js"}}" defer></script>
<style> <style>
.meters { display:grid; grid-template-columns:repeat(auto-fit, minmax(240px, 1fr)); gap:20px; margin-bottom:20px; } .meters { display:grid; grid-template-columns:repeat(auto-fit, minmax(240px, 1fr)); gap:20px; margin-bottom:20px; }
.meters .card { margin:0; } .meters .card { margin:0; }
@@ -42,7 +44,7 @@
</head> </head>
<body> <body>
<header class="topbar"> <header class="topbar">
<div class="brand">LPRA · Статус - {{.Agent.Name}} <span class="muted">({{.Agent.Hostname}})</span></div> <div class="brand"><a class="home" href="/" title="К списку машин">LPRA</a> · Статус - {{.Agent.Name}} <span class="muted">({{.Agent.Hostname}})</span></div>
<nav> <nav>
<a href="/">← Машины</a> <a href="/">← Машины</a>
<a href="/agents/{{.Agent.ID}}/terminal">Терминал</a> <a href="/agents/{{.Agent.ID}}/terminal">Терминал</a>
+200 -205
View File
@@ -4,17 +4,145 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Терминал · {{.Agent.Name}} - LPRA</title> <title>Терминал · {{.Agent.Name}} - LPRA</title>
<link rel="stylesheet" href="/static/app.css"> <link rel="stylesheet" href="{{asset "app.css"}}">
<link rel="stylesheet" href="/static/xterm.css"> <script src="{{asset "pin.js"}}"></script>
<script src="{{asset "scroll.js"}}" defer></script>
<link rel="stylesheet" href="{{asset "xterm.css"}}">
<style> <style>
#status.st-ok { color:#37c871; } #status.st-ok { color:#37c871; }
#status.st-err { color:#ff5c5c; } #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, /* 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. */ 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; } .keys { display:none; }
body.touch { position:fixed; left:0; right:0; top:0; height:100%; display:flex; flex-direction:column; overflow:hidden; } body.touch { position:fixed; left:0; right:0; top:0; height:100%; }
body.touch .wrap { flex:1; min-height:0; display:flex; width:100%; margin:0; padding:6px; } body.touch .area { margin:6px; }
body.touch #terminal { flex:1; min-width:0; height:auto; } 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 { body.touch .keys {
display:grid; grid-template-columns:repeat(7, 1fr); gap:4px; flex:none; 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); padding:4px 4px calc(4px + env(safe-area-inset-bottom)); background:var(--panel); border-top:1px solid var(--border);
@@ -33,24 +161,31 @@
.selmenu[hidden] { display:none; } .selmenu[hidden] { display:none; }
.selmenu button { width:42px; height:42px; padding:0; display:inline-flex; align-items:center; justify-content:center; .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; } touch-action:manipulation; -webkit-tap-highlight-color:transparent; }
body.touch #terminal { -webkit-user-select:none; user-select:none; -webkit-touch-callout:none; } body.touch .area { -webkit-user-select:none; user-select:none; -webkit-touch-callout:none; }
</style> </style>
</head> </head>
<body> <body>
<header class="topbar"> <header class="topbar">
<div class="brand">LPRA · Терминал - {{.Agent.Name}} <span class="muted">({{.Agent.TerminalUser}}@{{.Agent.Hostname}})</span></div> <div class="brand"><a class="home" href="/" title="К списку машин">LPRA</a> · Терминал - {{.Agent.Name}} <span class="muted">({{.Agent.TerminalUser}}@{{.Agent.Hostname}})</span></div>
<nav> <nav>
<a href="/">← Машины</a> <a href="/">← Машины</a>
<a href="/agents/{{.Agent.ID}}/files">Файлы</a> <a href="/agents/{{.Agent.ID}}/files">Файлы</a>
<a href="/agents/{{.Agent.ID}}/forwards">Порты</a> <a href="/agents/{{.Agent.ID}}/forwards">Порты</a>
<a href="/agents/{{.Agent.ID}}/status">Статус</a> <a href="/agents/{{.Agent.ID}}/status">Статус</a>
<a href="/agents/{{.Agent.ID}}/edit">Изменить</a> <a href="/agents/{{.Agent.ID}}/edit">Изменить</a>
<span id="status" class="muted" style="margin-left:28px">подключение…</span> <span id="status" class="muted" style="margin-left:28px"></span>
</nav> </nav>
</header> </header>
<div class="wrap" style="max-width:none"> <div class="tabs" id="tabs">
<div id="terminal"></div> <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>
<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> <div class="selmenu" id="selmenu" hidden>
<button type="button" data-a="copy" title="Копировать" aria-label="Копировать"> <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> <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>
@@ -82,147 +217,15 @@
<button type="button" data-k="pgdn">PGDN</button> <button type="button" data-k="pgdn">PGDN</button>
</div> </div>
<script src="/static/xterm.js"></script> <script src="{{asset "xterm.js"}}"></script>
<script src="/static/addon-fit.js"></script> <script src="{{asset "addon-fit.js"}}"></script>
<script> <script>
(function () { window.TERM_PAGE = { agent: { id: {{.Agent.ID}}, name: {{.Agent.Name}}, host: {{.Agent.Hostname}}, user: {{.Agent.TerminalUser}} } };
const agentID = {{.Agent.ID}}; // Touch helpers, for the terminal being typed in: the extra keys with their
const statusEl = document.getElementById('status'); // CTRL / ALT, and selecting and copying with a long press.
window.TermKeys = (function () {
const touch = window.matchMedia('(pointer: coarse)').matches; const touch = window.matchMedia('(pointer: coarse)').matches;
if (touch) document.body.classList.add('touch'); const cur = () => window.TermPane && window.TermPane.active();
function setStatus(t, cls) { statusEl.textContent = t; statusEl.className = cls || ''; }
const term = new Terminal({
cursorBlink: true,
fontSize: touch ? 13 : 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);
}
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;
}
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
});
}
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');
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');
}
// Input goes out one request at a time, so on a slow link a keystroke can
// never overtake the one before it; what is typed meanwhile is sent together
// next. A request that fails goes again under the same number, and the
// server drops one it already has.
let inSeq = 0, inBusy = false;
const inQueue = [];
function queueIn(m) {
if (closed || !sid) return;
const last = inQueue[inQueue.length - 1];
if (last && last.t === 'i' && m.t === 'i') last.text += m.text;
else if (last && last.t === 'r' && m.t === 'r') inQueue[inQueue.length - 1] = m;
else inQueue.push(m);
pumpIn();
}
async function pumpIn() {
if (inBusy || !inQueue.length) return;
inBusy = true;
while (inQueue.length && !closed) {
const m = inQueue.shift();
const body = m.t === 'i'
? { t: 'i', d: bytesToB64(new TextEncoder().encode(m.text)), seq: ++inSeq }
: { t: 'r', cols: m.cols, rows: m.rows, seq: ++inSeq };
for (let wait = 300; !closed; wait = Math.min(wait * 2, 5000)) {
let r = null;
try {
r = await fetch(base + 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; // taken, or refused for good
await new Promise(res => setTimeout(res, wait));
}
}
inBusy = false;
}
function send(data) { queueIn({ t: 'i', text: data }); }
// What the keyboard types gets the CTRL / ALT of the extra keys, if armed.
term.onData(function (data) { send(withMods(data)); });
// --- Extra keys (touch screens) ---
// CTRL and ALT: a tap arms the key for the next key press, a long press // CTRL and ALT: a tap arms the key for the next key press, a long press
// keeps it on until tapped again. // keeps it on until tapped again.
const mods = { ctrl: 0, alt: 0 }; // 0 off, 1 next key, 2 locked const mods = { ctrl: 0, alt: 0 }; // 0 off, 1 next key, 2 locked
@@ -255,7 +258,7 @@
// xterm's modifier parameter: 1 + shift(1) + alt(2) + ctrl(4). // 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 CSI = { up: 'A', down: 'B', right: 'C', left: 'D', home: 'H', end: 'F' };
const TILDE = { pgup: 5, pgdn: 6 }; const TILDE = { pgup: 5, pgdn: 6 };
function keySeq(k) { function keySeq(k, term) {
const m = useMods(), p = 1 + (m.alt ? 2 : 0) + (m.ctrl ? 4 : 0); const m = useMods(), p = 1 + (m.alt ? 2 : 0) + (m.ctrl ? 4 : 0);
if (CSI[k]) { if (CSI[k]) {
if (p > 1) return '\x1b[1;' + p + CSI[k]; if (p > 1) return '\x1b[1;' + p + CSI[k];
@@ -286,27 +289,42 @@
} }
return; return;
} }
send(keySeq(b.dataset.k)); 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('pointercancel', function () { if (press) { clearTimeout(press.timer); press = null; } });
b.addEventListener('contextmenu', function (e) { e.preventDefault(); }); b.addEventListener('contextmenu', function (e) { e.preventDefault(); });
}); });
function sendResize() { queueIn({ t: 'r', cols: term.cols, rows: term.rows }); }
// --- Selecting and copying on touch screens --- // --- Selecting and copying on touch screens ---
// xterm.js has no touch selection of its own. A long press selects the word // 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 // 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. // 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;
}
function selectionChanged(s) {
if (!touch || s !== cur()) return;
if (s.term.hasSelection()) showMenu();
else if (!selecting) menu.hidden = true;
}
if (touch) { if (touch) {
const box = document.getElementById('terminal'), menu = document.getElementById('selmenu'); const box = document.getElementById('area');
let timer = null, start = null, anchor = null, selecting = false; let timer = null, start = null, anchor = null;
function cellAt(x, y) { function cellAt(term, el, x, y) {
const r = box.querySelector('.xterm-screen').getBoundingClientRect(); 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 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)))); 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 }; return { col: col, row: row + term.buffer.active.viewportY };
} }
function selectWord(c) { function selectWord(term, c) {
const line = term.buffer.active.getLine(c.row); const line = term.buffer.active.getLine(c.row);
const text = line ? line.translateToString(false) : ''; const text = line ? line.translateToString(false) : '';
const word = ch => ch && !/\s/.test(ch); const word = ch => ch && !/\s/.test(ch);
@@ -318,37 +336,33 @@
term.select(a, c.row, b - a + 1); term.select(a, c.row, b - a + 1);
anchor = { from: c.row * term.cols + a, to: c.row * term.cols + b }; anchor = { from: c.row * term.cols + a, to: c.row * term.cols + b };
} }
function stretch(c) { function stretch(term, c) {
const p = c.row * term.cols + c.col; const p = c.row * term.cols + c.col;
const from = Math.min(anchor.from, p), to = Math.max(anchor.to, p); 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); term.select(from % term.cols, Math.floor(from / term.cols), to - from + 1);
} }
function showMenu() { function hideMenu() { menu.hidden = true; const s = cur(); if (s) s.term.clearSelection(); anchor = null; }
const keys = document.getElementById('keys').getBoundingClientRect();
menu.style.top = '';
menu.style.bottom = (window.innerHeight - keys.top + 8) + 'px';
menu.querySelector('[data-a="copy"]').disabled = !term.hasSelection();
menu.hidden = false;
}
function hideMenu() { menu.hidden = true; term.clearSelection(); anchor = null; }
box.addEventListener('touchstart', function (e) { box.addEventListener('touchstart', function (e) {
if (e.touches.length !== 1) { clearTimeout(timer); return; } if (e.touches.length !== 1 || !e.target.closest('.pane-body')) { clearTimeout(timer); return; }
const t = e.touches[0]; const t = e.touches[0];
start = { x: t.clientX, y: t.clientY }; start = { x: t.clientX, y: t.clientY };
if (!menu.hidden) hideMenu(); if (!menu.hidden) hideMenu();
clearTimeout(timer); clearTimeout(timer);
timer = setTimeout(function () { timer = setTimeout(function () {
const s = cur();
if (!s) return;
selecting = true; selecting = true;
selectWord(cellAt(start.x, start.y)); selectWord(s.term, cellAt(s.term, s.el, start.x, start.y));
showMenu();
if (navigator.vibrate) navigator.vibrate(15); if (navigator.vibrate) navigator.vibrate(15);
}, 450); }, 450);
}, { passive: true, capture: true }); }, { passive: true, capture: true });
box.addEventListener('touchmove', function (e) { box.addEventListener('touchmove', function (e) {
const t = e.touches[0]; const t = e.touches[0], s = cur();
if (selecting) { if (selecting && s) {
e.preventDefault(); // no scrolling while the selection stretches e.preventDefault(); // no scrolling while the selection stretches
e.stopPropagation(); e.stopPropagation();
stretch(cellAt(t.clientX, t.clientY)); 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) { } else if (start && Math.hypot(t.clientX - start.x, t.clientY - start.y) > 8) {
clearTimeout(timer); // a swipe: let it scroll clearTimeout(timer); // a swipe: let it scroll
} }
@@ -363,6 +377,11 @@
}, { passive: false, capture: true }); }, { passive: false, capture: true });
box.addEventListener('contextmenu', function (e) { e.preventDefault(); }); box.addEventListener('contextmenu', function (e) { e.preventDefault(); });
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) { function copyText(text) {
if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(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. // Plain http: the old way, through a field that is there for a moment.
@@ -377,52 +396,28 @@
} }
menu.addEventListener('pointerdown', function (e) { e.preventDefault(); }); menu.addEventListener('pointerdown', function (e) { e.preventDefault(); });
menu.addEventListener('click', function (e) { menu.addEventListener('click', function (e) {
const b = e.target.closest('button'); const b = e.target.closest('button'), s = cur();
if (!b) return; if (!b || !s) return;
const a = b.dataset.a; const a = b.dataset.a;
if (a === 'copy') { if (a === 'copy') {
const text = term.getSelection(); const text = s.term.getSelection();
hideMenu(); hideMenu();
copyText(text).then(function () { setStatus('скопировано', 'st-ok'); setTimeout(function () { setStatus(connected ? 'подключено' : '', connected ? 'st-ok' : ''); }, 1500); }, copyText(text).then(function () { flash('скопировано', 'st-ok'); }, function (err) { flash('не скопировалось: ' + err.message, 'st-err'); });
function (err) { setStatus('не скопировалось: ' + err.message, 'st-err'); });
} else if (a === 'paste') { } else if (a === 'paste') {
hideMenu(); hideMenu();
if (!navigator.clipboard || !navigator.clipboard.readText) { setStatus('вставка: браузер не даёт буфер', 'st-err'); return; } if (!navigator.clipboard || !navigator.clipboard.readText) { flash('вставка: браузер не даёт буфер', 'st-err'); return; }
navigator.clipboard.readText().then(function (t) { if (t) term.paste(t); }, function () { setStatus('вставка: нет доступа к буферу', 'st-err'); }); navigator.clipboard.readText().then(function (t) { if (t) s.term.paste(t); }, function () { flash('вставка: нет доступа к буферу', 'st-err'); });
} else if (a === 'all') { } else if (a === 'all') {
term.selectAll(); s.term.selectAll();
showMenu(); showMenu();
} else { } else {
hideMenu(); hideMenu();
} }
}); });
} }
return { withMods: withMods, selectionChanged: selectionChanged };
function refit() {
if (touch && window.visualViewport) {
// Follow the visible area, so the extra keys sit right above the
// on-screen keyboard.
const vv = window.visualViewport;
document.body.style.top = vv.offsetTop + 'px';
document.body.style.height = vv.height + 'px';
}
fit.fit();
}
window.addEventListener('resize', refit);
if (touch && window.visualViewport) {
window.visualViewport.addEventListener('resize', refit);
window.visualViewport.addEventListener('scroll', refit);
}
refit();
term.onResize(sendResize);
window.addEventListener('pagehide', function () {
if (sid) navigator.sendBeacon(base + sid + '/close');
});
term.focus();
open();
})(); })();
</script> </script>
<script src="{{asset "terminal.js"}}"></script>
</body> </body>
</html> </html>
+203
View File
@@ -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
}
}
}