Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86f7c96807 | ||
|
|
cf0b2baf75 | ||
|
|
2ab2aa1e38 | ||
|
|
087eabf3b4 | ||
|
|
57c2f1338f | ||
|
|
4fb26a9652 | ||
|
|
46f7f743b3 | ||
|
|
70729ef63f | ||
|
|
2d6b3916c8 | ||
|
|
35ad2d435b | ||
|
|
37ae830cbf | ||
|
|
ffe7c138f1 | ||
|
|
ae8fffe7d8 | ||
|
|
84b42c5369 | ||
|
|
8aba30a093 | ||
|
|
9337bd26b0 | ||
|
|
8bbb2b0ccb | ||
|
|
80ed7d4ffa | ||
|
|
6442c39360 | ||
|
|
d5d57fbf1b | ||
|
|
7ae75e910a | ||
|
|
74a22ab57f | ||
|
|
060975a443 | ||
|
|
fdd0483286 | ||
|
|
fcf9417f5a | ||
|
|
3641a3627c | ||
|
|
9912255289 | ||
|
|
9bf30b281e | ||
|
|
fa62f2c5b6 | ||
|
|
3b7a6271ac | ||
|
|
5216aed237 | ||
|
|
c003b51b4c | ||
|
|
96163ebf86 | ||
|
|
bc1736f105 | ||
|
|
b23943ff42 | ||
|
|
6d8f39f2a5 | ||
|
|
458d826de6 | ||
|
|
0c44ef006d | ||
|
|
187dc2ae97 | ||
|
|
055020c3bb | ||
|
|
4da1cc4200 | ||
|
|
4b7220fffb | ||
|
|
434900a168 | ||
|
|
6db78399ac | ||
|
|
352b016384 | ||
|
|
b272aed8fd | ||
|
|
ce7c417884 | ||
|
|
bfa38e7d18 | ||
|
|
00617451aa | ||
|
|
92a90646a5 | ||
|
|
69600133a4 | ||
|
|
3044e650d3 | ||
|
|
ed77c8e0c1 | ||
|
|
7d1996dd7d | ||
|
|
6916af4236 | ||
|
|
8d7489b577 | ||
|
|
847bcd68c0 | ||
|
|
a7e43ab700 | ||
|
|
d9c7c2dd42 | ||
|
|
94bf292494 | ||
|
|
1f930e5aea | ||
|
|
3f113ce8d6 | ||
|
|
46948f5ddc | ||
|
|
9572a35ad5 | ||
|
|
64ce8f2b72 | ||
|
|
e05ff05015 | ||
|
|
01dc45ecad | ||
|
|
d92696226e | ||
|
|
2009796421 | ||
|
|
f2740c50b4 | ||
|
|
a30b89f1d8 | ||
|
|
7e4daeda4c | ||
|
|
2859e69abe | ||
|
|
f5d6bd30e8 | ||
|
|
012c0af7f9 | ||
|
|
113c63fd1b | ||
|
|
7b9daa524e | ||
|
|
0ada0899cf | ||
|
|
8e2028e7f9 | ||
|
|
698bc68f99 | ||
|
|
0fa3de757c | ||
|
|
0279c1d887 | ||
|
|
0ccac0bc82 | ||
|
|
e5a45b872a | ||
|
|
9e684605ef | ||
|
|
13897c7478 | ||
|
|
4d2cfe8244 | ||
|
|
25ffa60d09 | ||
|
|
baee90b564 | ||
|
|
79828e3f8a | ||
|
|
021da93229 | ||
|
|
36dc12b69d | ||
|
|
1297ab36d0 | ||
|
|
fd382d6313 | ||
|
|
fc488717d3 | ||
|
|
dbb46173a3 | ||
|
|
b47e824692 | ||
|
|
5eca9ca215 | ||
|
|
467bd3cb4f | ||
|
|
18d6684c55 | ||
|
|
1a38c2ac18 | ||
|
|
5a4dc596d5 | ||
|
|
5a204ae9bb | ||
|
|
119b524825 | ||
|
|
29353e5e6a | ||
|
|
47f1b2e55b | ||
|
|
73bb720ec6 | ||
|
|
6dd1120a41 | ||
|
|
67222e2e05 | ||
|
|
e7b5d28470 | ||
|
|
d2d6fe0100 | ||
|
|
e983fc179a | ||
|
|
89d5bfd3ab | ||
|
|
55c37a0097 | ||
|
|
cefdae267b | ||
|
|
44e7137d86 | ||
|
|
10ffa393ca | ||
|
|
114c6fc530 | ||
|
|
0aa066fd01 | ||
|
|
21436d5c73 | ||
|
|
65359f5497 |
No files matched your search
@@ -34,7 +34,6 @@ go.work.sum
|
||||
|
||||
|
||||
bin
|
||||
dist
|
||||
*.db
|
||||
.DS_Store
|
||||
*/.DS_Store
|
||||
@@ -1,74 +1,189 @@
|
||||
# LPRA - LongPolling Remote Access
|
||||
|
||||
Лёгкий self-hosted сервис для доступа к Linux-машинам **без белого IP** (за NAT).
|
||||
Агент на клиенте сам исходяще подключается к серверу по HTTP long-polling; админ
|
||||
в веб-панели видит список машин и открывает по каждой **терминал в браузере** (по умолчанию от root, пользователя можно сменить).
|
||||
Агент на клиенте сам исходяще подключается к серверу - по WebSocket, а где он не проходит,
|
||||
по HTTP long-polling. В веб-панели видно список машин, и по каждой есть терминал в браузере,
|
||||
файловый менеджер, проброс портов и статус.
|
||||
|
||||
```
|
||||
браузер ──WS──> [nginx :8445 TLS] ──> [Go server 127.0.0.1:8091] <──long-poll── агент (root, за NAT)
|
||||
│
|
||||
SQLite
|
||||
браузер ──HTTPS/WS──> [Cloudflare] ──> [nginx TLS] ──> [Go server 127.0.0.1:8091] <──WS / long-poll── агент (за NAT)
|
||||
│ ▲
|
||||
SQLite └──── прямой TCP/UDP-туннель :8446 (проброс tcp/udp)
|
||||
```
|
||||
|
||||
## Возможности
|
||||
- **Одна страница** - Машины, WorkSpace (терминалы и файлы), Настройки; переходы без перезагрузки.
|
||||
Вкладки WorkSpace живут, пока открыта вкладка браузера: уход на другие страницы их не
|
||||
закрывает, перезагрузка сбрасывает (браузер сначала спросит). «Порты», «Статус» и «Изменить»
|
||||
машины открываются окном поверх текущей страницы - из списка машин или правым кликом
|
||||
по заголовку вкладки или панели.
|
||||
- **WorkSpace** - вкладки на нескольких машинах сразу, до четырёх панелей в одной вкладке -
|
||||
терминал или файловый менеджер (перетаскиванием, с изменяемыми размерами), переименование
|
||||
вкладок. На телефоне - панель клавиш (Esc, Ctrl, Alt, стрелки и т.д.).
|
||||
- **SSH-терминал** - в меню «+»: агент открывает SSH-соединение к другому хосту (по паролю,
|
||||
ключу - можно загрузить файл - или своими ключами из `~/.ssh`), шелл открывается во вкладке,
|
||||
а шаги подключения видны в статусе. Ключ хоста запоминается при первом входе
|
||||
(`ssh_known_hosts` рядом с `state.json` агента); если он сменится, вход откажет.
|
||||
- **Файлы** - просмотр, редактор с подсветкой, hex-редактор, предпросмотр картинок, видео, PDF,
|
||||
баз SQLite с правкой (значение по клику, добавление и удаление строк, вкладка SQL; агент меняет базу через сам SQLite, так что работающая база не ломается); загрузка и скачивание до 512 МБ кусками по 4 МБ (проходит через Cloudflare);
|
||||
свойства (chmod/chown), zip/unzip.
|
||||
- **Файлы по SSH** - в меню «+» › «Файловый менеджер» › «SSH-хост…»: машина подключается к хосту
|
||||
и работает с файлами командами по SSH, SFTP не нужен. Есть всё, кроме редактора SQLite;
|
||||
zip и распаковка - если на хосте установлены `zip` и `unzip`. Оборвалось подключение -
|
||||
панель предложит переподключиться.
|
||||
- **Перетаскивание файлов** - строку (или все отмеченные) в другую файловую панель или в папку
|
||||
в ней: окно «Переместить / Копировать» у места броска, между машинами и SSH-хостами - с
|
||||
прогрессом. В терминал той же машины или того же SSH-хоста - вставляется путь в кавычках,
|
||||
в терминал другой - нельзя, с подсказкой почему. Задержать над заголовком вкладки - она
|
||||
откроется. Только на компьютере.
|
||||
- **Перемещение и копирование** - внутри машины или, кнопкой «сеть» в том же окне, **на другую
|
||||
машину или на SSH-хост**: папка уходит потоком tar без промежуточных файлов, с прогрессом,
|
||||
скоростью и отменой. «Переместить» удаляет исходник после успешной отправки. Правила для
|
||||
существующего: сообщить / заменить / оставить как есть.
|
||||
- **ИИ-ассистент** - круглая кнопка чата (на компьютере её и окно можно перетаскивать); каждый
|
||||
включает себе сам в профиле. Видит вкладки, активный терминал или папку и список ваших машин,
|
||||
отвечает потоком. Действия - кнопками: выполнить команду в терминале нужной машины, прочитать,
|
||||
показать или записать файл - всё с правами пользователя. Плашка «Агент» - делает шаги сам,
|
||||
спрашивая перед каждой командой и записью; «Думать» - сначала рассуждает (свёрнуто над
|
||||
ответом). Модель задаёт администратор в «Настройки › ИИ-ассистент»: Gradio (по умолчанию -
|
||||
прокси к Qwen Omni), OpenAI-совместимый API или Anthropic; ключ хранится на сервере.
|
||||
Плашка «Поиск» даёт ассистенту искать в интернете; поисковик выбирается там же: DuckDuckGo
|
||||
(по умолчанию, без настройки), свой SearXNG (нужен `search.formats: [json]`), Brave Search API
|
||||
или Tavily (по ключу). Если выбранный не ответил, поиск идёт через DuckDuckGo.
|
||||
- **Сохранённые SSH-хосты** - в окне SSH-подключения галочка «Сохранить»: хост и вход (пароль или
|
||||
ключ) хранятся на сервере, у каждого пользователя свои, зашифрованы AES-256-GCM ключом сервера.
|
||||
Ключ берётся из `LPRA_SECRET_KEY` (32 байта, hex или base64) или из файла `<база>.key` рядом с
|
||||
базой, который создаётся сам - храните его вместе с бэкапом базы, без него сохранённые входы
|
||||
не расшифровать.
|
||||
- **Проброс портов** - `http` через панель, `tcp`/`udp` через прямой туннель.
|
||||
- **Статус** - нагрузка, память, диски, сеть, тест скорости канала до сервера.
|
||||
- **Вход** - пароль и/или ключи доступа (passkeys); пароль можно выключить, когда есть ключ.
|
||||
- **PIN-код** (необязательный, 4 цифры) - спрашивается в каждой новой вкладке браузера, но не при
|
||||
перезагрузке. На устройстве можно не спрашивать его 5/15/30/60 минут после ввода.
|
||||
Пять неверных попыток - выход из сессии.
|
||||
|
||||
## Пользователи и роли
|
||||
- **Администратор** видит всё: все машины, настройки, пользователей, ufw.
|
||||
- **Пользователь** видит только выданные ему машины (терминал, файлы, порты, статус, «Изменить» без удаления);
|
||||
настроек, других пользователей и чужих машин для него нет.
|
||||
|
||||
Роль и машины задаются в «Настройки → Пользователи» (квадратная кнопка у пользователя открывает список машин).
|
||||
Там же галочка «Пароль» включает или выключает вход по паролю (выключить можно, когда у человека есть ключ доступа),
|
||||
а «Закрепить» не даёт ему поменять это самому. Последнего администратора нельзя удалить или понизить.
|
||||
|
||||
## База данных
|
||||
По умолчанию - SQLite (файл из `-db`, по умолчанию `lpra.db`). В «Настройки › База данных» можно выбрать
|
||||
PostgreSQL: хост, порт, база, пользователь, пароль, SSL; кнопка «Проверить и сохранить» подключается и
|
||||
записывает выбор в `<база>.backend.json` рядом с SQLite-файлом (права 0600), «Перезапустить сервер»
|
||||
завершает процесс - systemd (`Restart=always`) поднимает его уже на PostgreSQL. При первом запуске на
|
||||
пустой PostgreSQL все данные копируются из SQLite-файла, сам файл остаётся как был (к нему можно
|
||||
вернуться тем же выбором). История ИИ-ассистента тоже хранится в базе - у каждого пользователя
|
||||
последние 200 сообщений.
|
||||
|
||||
## Компоненты
|
||||
- **Сервер** (`cmd/server`) - админ-панель + точка встречи для агентов. За nginx (TLS).
|
||||
- **Агент** (`cmd/agent`) - Linux-бинарь, systemd-сервис от root, PTY по запросу.
|
||||
- **install.sh** - интерактивный установщик, отдаётся сервером как статика.
|
||||
- **Сервер** (`cmd/server`) - панель и точка встречи для агентов. Ставится за nginx (TLS).
|
||||
- **Агент** (`cmd/agent`) - бинарь для Linux (amd64/arm64), systemd-сервис от root.
|
||||
- **install.sh** - установщик агента, отдаётся сервером.
|
||||
|
||||
## Сборка
|
||||
```sh
|
||||
./build.sh
|
||||
```
|
||||
Получаются `bin/lpra-server` и `dist/agent-linux-{amd64,arm64}`.
|
||||
Получаются `bin/lpra-server*` и `dist/agent-linux-{amd64,arm64}`.
|
||||
Версия задаётся в одном месте - `VERSION.txt`, она вшивается в сервер и агент.
|
||||
|
||||
## Запуск сервера
|
||||
```sh
|
||||
./bin/lpra-server -addr 127.0.0.1:8091 -dist ./dist -db ./lpra.db
|
||||
```
|
||||
Поставьте перед ним nginx по `deploy/nginx.conf.example` (TLS, long-poll/WS).
|
||||
Локально без TLS можно добавить `-insecure-cookies`.
|
||||
| Флаг | Переменная | По умолчанию | |
|
||||
|---|---|---|---|
|
||||
| `-addr` | `LPRA_ADDR` | `127.0.0.1:8091` | адрес HTTP (за nginx) |
|
||||
| `-db` | `LPRA_DB` | `lpra.db` | база SQLite |
|
||||
| `-dist` | `LPRA_DIST` | `dist` | бинарники агента для `/download/` |
|
||||
| `-tunnel` | `LPRA_TUNNEL_ADDR` | `:8446` | прямой туннель TCP+UDP (пусто - выключен) |
|
||||
| `-insecure-cookies` | `LPRA_INSECURE_COOKIES=1` | | куки без Secure - только для локального запуска без TLS |
|
||||
|
||||
Первый запуск: откройте `/setup` - мастер создаст администратора. Дальше `/settings`:
|
||||
адрес хоста (по умолчанию `https://localhost:8445`), enroll-секрет и готовая
|
||||
команда установки.
|
||||
Пример юнита - `deploy/lpra-server.service`.
|
||||
|
||||
## Установка агента на клиент
|
||||
Одной командой (под root):
|
||||
```sh
|
||||
curl -fsSL https://localhost:8445/install.sh | sudo HOST=https://localhost:8445 ENROLL_SECRET=<секрет> sh
|
||||
Первый запуск: откройте `/setup` - мастер создаст администратора. Дальше в `/settings`:
|
||||
адрес хоста, enroll-секрет, короткий код установки и готовая команда.
|
||||
|
||||
### nginx
|
||||
Конфиг - `deploy/nginx.conf.example`. Обязательно:
|
||||
- заголовки `Upgrade` / `Connection` (через `map $http_upgrade $connection_upgrade`) - без них
|
||||
WebSocket не пройдёт, и агенты и терминалы останутся на long-polling (работает, но медленнее
|
||||
на больших выводах);
|
||||
- `proxy_read_timeout` больше 25 с (удержание long-poll), `proxy_buffering off`.
|
||||
|
||||
### Прямой адрес (без Cloudflare)
|
||||
В «Настройки → Адрес хоста» можно задать второй, прямой адрес - например `https://direct.lpra.example.com`,
|
||||
DNS-запись без проксирования. Тогда:
|
||||
- агенты, не достучавшись до основного адреса 30 секунд, переходят на прямой и каждые 5 минут проверяют,
|
||||
не ожил ли основной; уже установленные агенты получают прямой адрес с сервера сами (он хранится в `state.json`);
|
||||
- установщик берёт адреса с того домена, с которого его скачали: с основного - агент работает через основной
|
||||
с резервом на прямой, с прямого - наоборот (и бинарник качается с прямого); если первый адрес не отвечает,
|
||||
бинарник качается со второго. В «Полных вариантах» есть короткая команда и через прямой адрес;
|
||||
- в «Изменить → Связь с сервером» у клиента, подключённого через прямой адрес, после статуса стоит «(direct)»;
|
||||
- если хост туннеля не задан, агентам отдаётся хост прямого адреса;
|
||||
- ключи доступа основного домена работают и на его поддомене.
|
||||
|
||||
### Cloudflare
|
||||
Работает за проксирующим Cloudflare: WebSocket он пропускает, файлы идут кусками меньше лимита
|
||||
на запрос, статика с хешем в имени кэшируется. Чтобы панель видела настоящие IP (ограничение
|
||||
попыток входа и кода установки считается по адресу), включите в nginx real IP:
|
||||
```nginx
|
||||
real_ip_header CF-Connecting-IP;
|
||||
set_real_ip_from 173.245.48.0/20; # и остальные диапазоны с https://www.cloudflare.com/ips/
|
||||
```
|
||||
Или интерактивно (спросит секрет, а если агент уже стоит - предложит удалить):
|
||||
Прямой туннель (`:8446`) - это не HTTP, через проксирующий Cloudflare он не идёт: в
|
||||
«Настройки → Прямой туннель» укажите для агентов IP хоста или DNS-имя без проксирования.
|
||||
|
||||
## Установка агента
|
||||
Короткой командой из `/settings` (код из 3-24 букв и цифр, кнопка «Новый код» меняет его):
|
||||
```sh
|
||||
curl -fsSL https://localhost:8445/install.sh | sudo sh
|
||||
curl -fsSL https://panel.example/i/<код> | sudo sh
|
||||
```
|
||||
Или полной:
|
||||
```sh
|
||||
curl -fsSL https://panel.example/install.sh | sudo HOST=https://panel.example ENROLL_SECRET=<секрет> sh
|
||||
```
|
||||
Без параметров установщик спросит секрет, а если агент уже стоит - предложит удалить.
|
||||
Обновить агента можно кнопкой в панели («Изменить» → обновить).
|
||||
|
||||
## Связь агента с сервером
|
||||
В «Изменить» → «Связь с сервером» для каждой машины:
|
||||
- **auto** (по умолчанию) - WebSocket; если он не подключается, long-polling ровно минуту, потом
|
||||
снова попытка WebSocket;
|
||||
- **ws** - только WebSocket;
|
||||
- **lp** - только long-polling.
|
||||
|
||||
## Проброс портов и прямой туннель
|
||||
Правила проброса создаются на странице машины. Режим `http` идёт через адрес панели (long-poll),
|
||||
режимы `tcp`/`udp` - через прямой туннель: агент держит исходящее соединение к порту туннеля хоста
|
||||
(флаг `-tunnel` / `LPRA_TUNNEL_ADDR`).
|
||||
Правила создаются на странице машины «Порты». Режим `http` идёт через адрес панели,
|
||||
`tcp`/`udp` - через прямой туннель: агент держит исходящее соединение к порту туннеля хоста,
|
||||
по нему мультиплексируются все соединения.
|
||||
|
||||
Порт туннеля и хост, к которому для него подключаются агенты (например, IP вместо домена панели),
|
||||
меняются в `/settings` → «Прямой туннель». Агенты забирают адрес с панели сами - при подключении и
|
||||
при каждом опросе, переустановка не нужна. Локальные `TUNNEL_ADDR` / `TUNNEL_PORT` в окружении агента
|
||||
имеют приоритет над настройкой панели (`TUNNEL_ADDR=off` отключает туннель).
|
||||
Порт туннеля и хост для агентов меняются в `/settings` → «Прямой туннель»; агенты забирают их
|
||||
сами, переустановка не нужна. Локальные `TUNNEL_ADDR` / `TUNNEL_PORT` в окружении агента имеют
|
||||
приоритет (`TUNNEL_ADDR=off` отключает туннель).
|
||||
|
||||
Если на хосте активен ufw и сервер запущен от root, панель сама открывает порт хоста правила
|
||||
(tcp для `http`/`tcp`, udp для `udp`) и порт туннеля для прямых режимов, а при удалении правила - закрывает.
|
||||
При `ProtectSystem=strict` в systemd-юните ufw нужны права на запись: `ReadWritePaths=/etc/ufw /run`.
|
||||
|
||||
## Версия
|
||||
Версия задаётся в одном месте - `VERSION.txt`; файл вшивается в сервер и агент при сборке.
|
||||
Если на хосте активен ufw и сервер запущен от root, панель сама открывает порты правил и порт
|
||||
туннеля, а при удалении правила закрывает. При `ProtectSystem=strict` в юните нужно
|
||||
`ReadWritePaths=/etc/ufw /run`.
|
||||
|
||||
## Пользователь терминала
|
||||
По умолчанию терминал открывается от пользователя, под которым запущен агент (на Linux - root).
|
||||
В окне «Изменить машину» можно выбрать другого пользователя машины или создать нового
|
||||
(имя, пароль, опционально - добавить в sudo). Смена и создание работают, только если агент запущен от root.
|
||||
Файловый менеджер по-прежнему работает от пользователя агента.
|
||||
По умолчанию терминал открывается от пользователя агента (root). В «Изменить машину» можно выбрать
|
||||
другого пользователя или создать нового (с паролем, по желанию с sudo) - если агент запущен от root.
|
||||
Файловый менеджер работает от пользователя агента.
|
||||
|
||||
## Безопасность
|
||||
- Пароли админов - bcrypt; сессии в HttpOnly/Secure/SameSite куках; CSRF на формах; rate-limit логина.
|
||||
- Агенты: общий enroll-секрет → индивидуальный токен (хранится хешем, сверка constant-time).
|
||||
- Открытие терминала пишется в таблицу `audit` (терминал даёт root-доступ), как и смена/создание пользователя.
|
||||
- Пароли админов - bcrypt; сессии в HttpOnly/Secure/SameSite куках; CSRF на формах;
|
||||
ограничение попыток входа, PIN и кода установки.
|
||||
- Ключи доступа (WebAuthn) - вход без пароля; пароль можно выключить совсем.
|
||||
- Агенты: общий enroll-секрет → индивидуальный токен (хранится хешем, сверка constant-time);
|
||||
туннель авторизуется HMAC от токена.
|
||||
- SSH-пароли и ключи передаются агенту один раз для подключения и нигде не сохраняются.
|
||||
- Открытие терминала и SSH, перенос файлов на другие машины, смена пользователя пишутся в `audit`.
|
||||
|
||||
## Скриншоты
|
||||
<img width="1256" height="709" alt="image" src="https://github.com/user-attachments/assets/5f0b30f1-d5e3-4b62-817e-4041641df6e3" />
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
0.0.7
|
||||
0.2.1
|
||||
+7
-1
@@ -11,6 +11,7 @@ import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/x-grk/longpolling-remote-access/internal/agent"
|
||||
@@ -21,6 +22,7 @@ func main() {
|
||||
host := envOr("HOST", "https://REDACTED:8445")
|
||||
cfg := agent.Config{
|
||||
Host: host,
|
||||
Fallback: strings.TrimRight(os.Getenv("FALLBACK_HOST"), "/"),
|
||||
EnrollSecret: os.Getenv("ENROLL_SECRET"),
|
||||
Name: os.Getenv("AGENT_NAME"),
|
||||
StatePath: envOr("STATE_PATH", "/etc/lpagent/state.json"),
|
||||
@@ -38,7 +40,11 @@ func main() {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
if err := agent.New(cfg).Run(ctx); err != nil {
|
||||
err := agent.New(cfg).Run(ctx)
|
||||
// Stopping (an update, a restart): hang the terminals up, so their shells
|
||||
// save their history instead of being killed.
|
||||
agent.CloseTerminals()
|
||||
if err != nil {
|
||||
log.Fatalf("agent: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ func main() {
|
||||
"do not mark cookies Secure (plain-HTTP local testing only)")
|
||||
flag.Parse()
|
||||
|
||||
db, err := server.OpenDB(*dbPath)
|
||||
db, err := server.OpenStore(*dbPath)
|
||||
if err != nil {
|
||||
log.Fatalf("open db: %v", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# nginx reverse proxy for the LPRA server.
|
||||
# TLS is terminated here; the Go server listens on 127.0.0.1:8091.
|
||||
#
|
||||
# Put this in /etc/nginx/sites-available/lp_agent and symlink it into
|
||||
# sites-enabled, then: sudo nginx -t && sudo systemctl reload nginx
|
||||
#
|
||||
# Agents connect by WebSocket (from 0.1.0), with HTTP long polling to fall
|
||||
# back on; the browser uses plain HTTP. The settings that matter:
|
||||
# - Upgrade / Connection headers -> let the agents' WebSocket through
|
||||
# - proxy_buffering off -> deliver long-poll responses immediately
|
||||
# - proxy_read_timeout 300s -> longer than the 25s long-poll hold
|
||||
#
|
||||
# NOTE: proxy_pass MUST start with "http://". A bare "proxy_pass 127.0.0.1:8091;"
|
||||
# causes nginx error: [emerg] invalid URL prefix.
|
||||
|
||||
# "Connection: upgrade" for a WebSocket, "close" for everything else.
|
||||
|
||||
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name example.ru;
|
||||
|
||||
client_max_body_size 120m;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8091;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
# Agents' WebSocket.
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
|
||||
# Long-poll behaviour: no buffering, generous read timeout.
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
# Pass uploads on as they arrive: the speed test through the browser
|
||||
# times them on the client.
|
||||
proxy_request_buffering off;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
}
|
||||
}
|
||||
@@ -4,17 +4,26 @@
|
||||
# Put this in /etc/nginx/sites-available/lp_agent and symlink it into
|
||||
# sites-enabled, then: sudo nginx -t && sudo systemctl reload nginx
|
||||
#
|
||||
# Everything (agent + browser terminal) is plain HTTP long-polling, so NO
|
||||
# WebSocket config is needed. The two settings that matter:
|
||||
# - proxy_buffering off -> deliver long-poll responses immediately
|
||||
# - proxy_read_timeout 120s -> longer than the 25s long-poll hold
|
||||
# Agents connect by WebSocket (from 0.1.0), with HTTP long polling to fall
|
||||
# back on; the browser uses plain HTTP. The settings that matter:
|
||||
# - Upgrade / Connection headers -> let the agents' WebSocket through
|
||||
# - proxy_buffering off -> deliver long-poll responses immediately
|
||||
# - proxy_read_timeout 300s -> longer than the 25s long-poll hold
|
||||
#
|
||||
# NOTE: proxy_pass MUST start with "http://". A bare "proxy_pass 127.0.0.1:8091;"
|
||||
# causes nginx error: [emerg] invalid URL prefix.
|
||||
|
||||
# "Connection: upgrade" for a WebSocket, "close" for everything else.
|
||||
|
||||
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 8445 ssl;
|
||||
server_name REDACTED;
|
||||
listen 443 ssl;
|
||||
server_name example.ru;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/REDACTED/fullchain.pem; # managed by Certbot
|
||||
ssl_certificate_key /etc/letsencrypt/live/REDACTED/privkey.pem; # managed by Certbot
|
||||
@@ -23,7 +32,7 @@ server {
|
||||
|
||||
# Files go through the panel in 4 MB pieces; 64m leaves room for a client
|
||||
# that predates the pieces, which takes a file of up to 32 MB in one request.
|
||||
client_max_body_size 64m;
|
||||
client_max_body_size 120m;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8091;
|
||||
@@ -33,6 +42,9 @@ server {
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
# Agents' WebSocket.
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
|
||||
# Long-poll behaviour: no buffering, generous read timeout.
|
||||
proxy_buffering off;
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,159 @@
|
||||
# LPRA 0.2.0 — рабочее пространство (подпроект 1)
|
||||
|
||||
Дата: 2026-10-15. Статус: спецификация утверждена.
|
||||
Подпроект 2 (встроенный ИИ-помощник) — отдельная спецификация, делается после этого.
|
||||
|
||||
## Цель
|
||||
|
||||
Работа с несколькими машинами в одном месте, без потери открытых терминалов при переходах по панели.
|
||||
Терминалы, файловые менеджеры (своих машин и по SSH) живут во вкладках одного рабочего пространства;
|
||||
файлы перетаскиваются между панелями и в терминал.
|
||||
|
||||
Критерии готовности:
|
||||
- переход Машины ↔ Терминал ↔ Настройки не обрывает сессии терминалов и не сбрасывает вкладки;
|
||||
- перезагрузка страницы сбрасывает пространство (с предупреждением браузера, если есть живые сессии);
|
||||
- файловый менеджер — тип панели во вкладке, в том числе по SSH;
|
||||
- перетаскивание файлов: между файловыми панелями (окно «переместить / копировать»), в терминал той же машины (вставка путей), в терминал другой машины — запрет с сообщением.
|
||||
|
||||
## Решения, принятые в обсуждении
|
||||
|
||||
| вопрос | решение |
|
||||
|---|---|
|
||||
| Страницы «Порты», «Статус», «Изменить» | окна поверх текущей страницы |
|
||||
| Файлы рядом с терминалами в одной вкладке | да: любая из до 4 панелей — терминал, SSH-терминал, файлы, SSH-файлы |
|
||||
| Перезагрузка при живых сессиях | вопрос браузера «Покинуть сайт?» |
|
||||
| Архитектура | одностраничная оболочка, остальное — HTML-фрагменты с сервера (вариант A) |
|
||||
| Файлы по SSH | без SFTP-библиотеки: команды на хосте через SSH, как у существующей передачи tar |
|
||||
| Состояние | одно на вкладку браузера, в памяти страницы; не сохраняется при перезагрузке |
|
||||
|
||||
## 1. Каркас и маршруты
|
||||
|
||||
**Оболочка.** Адреса `/`, `/terminal`, `/settings` отдают один шаблон `app.html`: шапка (LPRA, «Машины · Терминал · Настройки» — «Настройки» только администраторам, статус активной панели, аккаунт) и три области `<section data-view="machines|terminal|settings">`.
|
||||
Старые адреса `/agents/{id}/terminal` и `/agents/{id}/files` отдают ту же оболочку и при загрузке создают вкладку нужного типа для этой машины (закладки не ломаются).
|
||||
|
||||
**Роутер** (`static/app.js`, без библиотек):
|
||||
- перехватывает клики по внутренним ссылкам и `popstate`; меняет видимую область через `history.pushState`, подсвечивает пункт шапки;
|
||||
- область «Терминал» создаётся один раз и потом только скрывается (`hidden`) — сессии, WebSocket и xterm живут, пока жива вкладка браузера;
|
||||
- «Машины» и «Настройки» при каждом показе подгружаются заново как фрагмент: `GET /?frag=1`, `GET /settings?frag=1`.
|
||||
|
||||
**Фрагменты на сервере.** `render()` получает режим фрагмента (параметр `frag=1`): шаблон отдаёт только содержимое, без `<html>`, шапки и подключений. Общая разметка `head` и `topbar`, сейчас скопированная в каждый шаблон, выносится в `{{define}}`-блоки.
|
||||
|
||||
**Формы во фрагментах** (`app.js`, перехват `submit` внутри областей и окон):
|
||||
1. `onsubmit`-обработчики (`confirm(...)`) срабатывают как обычно; отказ — ничего не происходит;
|
||||
2. форма отправляется через `fetch` (`redirect: 'follow'`, тело — `FormData`);
|
||||
3. итоговый адрес ответа определяет, что перерисовать: тот же фрагмент по этому адресу (сохраняются `?err=...`, `?updated=1`, `#users`);
|
||||
4. формы на `/logout` и ссылки наружу работают обычной загрузкой страницы.
|
||||
|
||||
**Скрипты фрагментов.** Встроенные `<script>` во фрагментах не исполняются. Поведение страниц оформляется модулями с `mount(root)` / `unmount()`, которые вызывает роутер: копирование команд и коды установки (настройки), `users.js`, список машин с опросом присутствия.
|
||||
`pin.js`, `scroll.js`, `account.js` подключаются один раз в оболочке.
|
||||
|
||||
**Предупреждение при уходе.** `beforeunload` спрашивает «Покинуть сайт?», только если в рабочем пространстве есть живая сессия терминала или SSH-терминала.
|
||||
|
||||
## 2. Рабочее пространство и типы панелей
|
||||
|
||||
**Модель.** Вкладка содержит 1–4 панели в существующих раскладках (`1`, `h`, `v`, сетка), с изменением размеров и перетаскиванием панелей и вкладок, как сейчас. Тип панели:
|
||||
|
||||
| тип | что это |
|
||||
|---|---|
|
||||
| `term` | терминал на машине (как сейчас) |
|
||||
| `ssh` | SSH-терминал через машину (как сейчас) |
|
||||
| `files` | файловый менеджер на машине |
|
||||
| `sshfiles` | файловый менеджер на SSH-хосте через машину |
|
||||
|
||||
**Разделение кода** (сейчас всё в `terminal.js` и `files.html`):
|
||||
- `workspace.js` — вкладки, раскладки, разделение, перетаскивание панелей и вкладок, меню «+». С панелями работает через общий интерфейс: `el`, `kind`, `agent`, `title`, `state` и `text` (точка и строка статуса), `fit()`, `focus()`, `close()`, `acceptsDrop(item)`, `drop(item)`;
|
||||
- `termpane.js` — нынешний класс `Session` (терминал и SSH-терминал), вынесенный почти без изменений;
|
||||
- `filepane.js` — файловая панель (часть 3);
|
||||
- `fileviews.js` — общие окна файлов (просмотр и правка, hex, свойства, перемещение, загрузка, редактор SQLite).
|
||||
|
||||
**Меню «+»** (на компьютере и телефоне):
|
||||
- «Терминал ›» — «здесь (машина)», другие машины в сети, «SSH-хост…»;
|
||||
- «Файловый менеджер ›» — «здесь (машина)», другие машины в сети, «SSH-хост…».
|
||||
|
||||
«SSH-хост…» открывает существующее окно SSH-подключения; тип панели зависит от пункта меню. Последний SSH-хост (без секретов) запоминается, как сейчас.
|
||||
|
||||
**Из списка машин:** кнопка терминала → новая вкладка-терминал этой машины и переход в «Терминал»; кнопка файлов → новая вкладка «Файлы». Если пространства ещё нет, оно создаётся.
|
||||
|
||||
**Заголовки.** Вкладка файлов — иконка папки и `машина · папка`, путь обрезается слева, обновляется при смене папки. Переименование двойным кликом — как у терминалов. Точка статуса файловой панели — машина в сети / офлайн / ошибка.
|
||||
|
||||
**Телефон.** Вкладки и меню те же; разделения и перетаскивания, как и сейчас, только на компьютере.
|
||||
|
||||
## 3. Файловая панель
|
||||
|
||||
Содержимое нынешней страницы файлов как компонент `FilePane(source)`: строка пути, «Вверх», «Обновить», «Папка / Файл / Загрузить», таблица с отметками, «В zip», «Удалить выбранное», загрузка перетаскиванием из ОС.
|
||||
Состояние (текущая папка, список, отметки) — у экземпляра; элементы ищутся внутри корня панели, без глобальных `id`. Две файловые панели в одной вкладке не мешают друг другу.
|
||||
|
||||
**Общие окна** (`fileviews.js`) — одно на страницу. При открытии окну передаётся панель-источник: машина или SSH, путь, что обновить после действия.
|
||||
|
||||
**Источник данных** — один интерфейс (`list`, `read`, `write`, `mkdir`, `delete`, `move`, `copy`, `stat`, `chmod`, `download`, `upload`) с двумя реализациями:
|
||||
- машина — существующий `/api/panel/agents/{id}/fs/...`;
|
||||
- SSH-хост — новые задачи агента `sshfs.*` (ниже).
|
||||
|
||||
**Узкие панели** (меньше ~520 px, в разделённой вкладке): прячется колонка «Изменён», кнопки строки сворачиваются в меню «⋯» — как сейчас на телефоне.
|
||||
|
||||
### Файлы по SSH (`sshfiles`)
|
||||
|
||||
Агент держит одно SSH-подключение на панель: `sshConnect` и проверка ключа хоста из SSH-терминала. Подключение открывается при создании панели (окно «SSH-хост…»), получает id, закрывается при закрытии панели или через 10 минут простоя. Секреты передаются агенту один раз, панель их не хранит.
|
||||
|
||||
Операции — команды на хосте, пути в одинарных кавычках (`shq`):
|
||||
|
||||
| операция | команда |
|
||||
|---|---|
|
||||
| список | `stat` по содержимому папки; формат выбирается один раз при подключении: GNU/busybox `stat -c`, BSD/macOS `stat -f`; без `stat` — разбор `ls -la` без точного времени |
|
||||
| чтение, скачивание | `cat -- 'файл'`, поток через stdout |
|
||||
| запись, загрузка | `cat > 'файл.tmp' && mv 'файл.tmp' 'файл'`, поток через stdin |
|
||||
| папка, удаление, переименование | `mkdir -p`, `rm -rf`, `mv` |
|
||||
| права, владелец | `chmod`, `chown` |
|
||||
| копирование, перенос внутри хоста | `cp -a`, `mv` |
|
||||
| перенос между хостом и машинами | существующая передача tar по SSH |
|
||||
|
||||
Новые задачи агента: `sshfs.open` (подключиться, вернуть id и домашнюю папку), `sshfs.close`, `sshfs.list`, `sshfs.read`, `sshfs.write`, `sshfs.mkdir`, `sshfs.delete`, `sshfs.move`, `sshfs.copy`, `sshfs.stat`, `sshfs.chmod`, `sshfs.send` (скачивание через существующие куски `xfer`), `sshfs.put` (загрузка через куски `xfer`).
|
||||
Серверу — те же правила доступа, что у файловых операций машины; изменения пишутся в аудит.
|
||||
|
||||
Не входит: zip и распаковка на SSH-хосте, редактор SQLite по SSH — эти кнопки в `sshfiles` скрыты. Новых зависимостей нет.
|
||||
|
||||
## 4. Перетаскивание
|
||||
|
||||
**Что тащится:** строка файла или папки; если строка отмечена — все отмеченные строки панели. У курсора бейдж (имя или «N элементов»).
|
||||
Механизм — на `pointer`-событиях внутри страницы, тот же, что уже двигает вкладки и панели; подсказка над целью говорит, что произойдёт.
|
||||
|
||||
| цель | результат |
|
||||
|---|---|
|
||||
| другая файловая панель (любая машина или SSH-хост) | небольшое окно у места броска: «Переместить / Копировать / Отмена», куда (папка цели), «если уже есть: сообщить ▾». Внутри машины — `fs.copy`; внутри SSH-хоста — `cp`/`mv`; между разными — существующая передача tar с прогрессом в этом же окне |
|
||||
| папка в списке панели | то же, цель — эта папка |
|
||||
| терминал той же машины / того же SSH-хоста | пути в одинарных кавычках через пробел вставляются в терминал, без Enter |
|
||||
| терминал другой машины | цель подсвечивается красным, подсказка «Нельзя: файл на X, терминал на Y» |
|
||||
| заголовок вкладки | через 0,5 с наведения вкладка активируется, бросить можно в её панель |
|
||||
| пустое место / Esc | отмена |
|
||||
|
||||
Правила конфликтов имён — те же, что в окне «Переместить или копировать». Загрузка из ОС перетаскиванием — в любую файловую панель. На телефоне перетаскивания нет; остаётся окно перемещения с выбором машины или SSH.
|
||||
|
||||
## 5. Окна машины
|
||||
|
||||
«Порты», «Статус», «Изменить» — нынешние страницы как фрагменты (`?frag=1`) в общем окне `machinedlg` (заголовок «машина · раздел», закрытие; на телефоне — на весь экран).
|
||||
Открываются из кнопок строки в списке машин и из меню заголовка вкладки или панели (её машина). Формы — перехватом из части 1; результат (например, обновления клиента) показывается в окне.
|
||||
Живые части (графики и опрос статуса, тест скорости, присутствие) — модули `mount` / `unmount`; при закрытии окна останавливаются.
|
||||
|
||||
## Ошибки и крайние случаи
|
||||
|
||||
- Машина офлайн: терминал — как сейчас; файловая панель — «машина офлайн» и «повторить», при возвращении машины обновляется сама.
|
||||
- Нет доступа (роль «пользователь»): чужих машин нет в меню и списке; если доступ отобран при открытой вкладке — панель показывает «нет доступа».
|
||||
- Сессия истекла (401 или редирект на `/login`): сообщение и переход на вход.
|
||||
- Агент без `sshfs.*`: пункт «Файлы по SSH» сообщает «обновите агента».
|
||||
- Фрагмент не загрузился: ошибка и «повторить» в его области, остальное приложение работает.
|
||||
- SSH-подключение файловой панели оборвалось: следующая операция переподключается один раз; не вышло — панель сообщает и предлагает переподключиться.
|
||||
|
||||
## Проверка
|
||||
|
||||
На стенде (сервер, агент, тестовый sshd), Chrome на компьютере и в эмуляции телефона:
|
||||
1. Переходы Машины ↔ Терминал ↔ Настройки при идущем выводе в терминале — вывод не прерывается; «Назад / Вперёд»; перезагрузка — сброс и «Покинуть сайт?».
|
||||
2. Вкладки с терминалом, файлами, SSH-файлами; разделение, изменение размеров, перетаскивание панелей и вкладок.
|
||||
3. Файловая панель машины и SSH-хоста: список, просмотр, правка, загрузка, скачивание, создание, удаление, переименование, права.
|
||||
4. Перетаскивание: в соседнюю панель (своя машина, SSH), в папку, в терминал той же машины (вставка путей), в терминал чужой (запрет).
|
||||
5. Окна машины: формы, обновление клиента, остановка графиков при закрытии.
|
||||
6. Роли: обычный пользователь видит в меню и списке только свои машины.
|
||||
7. Регрессия: PIN, ключи доступа, установщик, редактор SQLite, перенос на другие машины, прямой адрес.
|
||||
|
||||
## Версия и документация
|
||||
|
||||
Версия 0.2.0. README — разделы о рабочем пространстве, файлах по SSH и перетаскивании.
|
||||
@@ -3,18 +3,34 @@ module github.com/x-grk/longpolling-remote-access
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
github.com/coder/websocket v1.8.12
|
||||
github.com/creack/pty v1.1.21
|
||||
golang.org/x/crypto v0.31.0
|
||||
golang.org/x/sys v0.28.0
|
||||
github.com/jackc/pgx/v5 v5.7.5
|
||||
golang.org/x/crypto v0.37.0
|
||||
golang.org/x/sys v0.32.0
|
||||
modernc.org/sqlite v1.24.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||
github.com/mattn/go-isatty v0.0.16 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/mod v0.17.0 // indirect
|
||||
golang.org/x/sync v0.13.0 // indirect
|
||||
golang.org/x/text v0.24.0 // indirect
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
|
||||
lukechampine.com/uint128 v1.2.0 // indirect
|
||||
modernc.org/cc/v3 v3.40.0 // indirect
|
||||
modernc.org/ccgo/v3 v3.16.13 // indirect
|
||||
modernc.org/libc v1.22.5 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
modernc.org/memory v1.5.0 // indirect
|
||||
modernc.org/opt v0.1.3 // indirect
|
||||
modernc.org/strutil v1.1.3 // indirect
|
||||
modernc.org/token v1.0.1 // indirect
|
||||
)
|
||||
@@ -1,28 +1,86 @@
|
||||
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
|
||||
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
|
||||
github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0=
|
||||
github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs=
|
||||
github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
|
||||
github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
|
||||
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
|
||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
||||
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
||||
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
|
||||
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
|
||||
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
|
||||
lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
|
||||
modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw=
|
||||
modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0=
|
||||
modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw=
|
||||
modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY=
|
||||
modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk=
|
||||
modernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ=
|
||||
modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM=
|
||||
modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM=
|
||||
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
|
||||
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
||||
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
||||
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
||||
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||
modernc.org/sqlite v1.24.0 h1:EsClRIWHGhLTCX44p+Ri/JLD+vFGo0QGjasg2/F9TlI=
|
||||
modernc.org/sqlite v1.24.0/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
|
||||
modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY=
|
||||
modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
|
||||
modernc.org/tcl v1.15.2 h1:C4ybAYCGJw968e+Me18oW55kD/FexcHbqH2xak1ROSY=
|
||||
modernc.org/tcl v1.15.2/go.mod h1:3+k/ZaEbKrC8ePv8zJWPtBSW0V7Gg9g8rkmhI1Kfs3c=
|
||||
modernc.org/token v1.0.1 h1:A3qvTqOwexpfZZeyI0FeGPDlSWX5pjZu9hF4lU+EKWg=
|
||||
modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
modernc.org/z v1.7.3 h1:zDJf6iHjrnB+WRD88stbXokugjyc0/pB91ri1gO6LZY=
|
||||
modernc.org/z v1.7.3/go.mod h1:Ipv4tsdxZRbQyLq9Q1M6gdbkxYzdlrciF2Hi/lS7nWE=
|
||||
+170
-16
@@ -18,6 +18,7 @@ import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
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).
|
||||
type Config struct {
|
||||
Host string // e.g. https://REDACTED:8445
|
||||
Fallback string // the direct address, from the installer (FALLBACK_HOST); may be empty
|
||||
EnrollSecret string
|
||||
Name string // optional friendly name
|
||||
StatePath string // where the enrolled id+token are persisted
|
||||
@@ -42,6 +44,8 @@ type Config struct {
|
||||
type state struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
Token string `json:"token"`
|
||||
// Fallback is the direct address as the server told it.
|
||||
Fallback string `json:"fallback,omitempty"`
|
||||
}
|
||||
|
||||
// Agent is a running client.
|
||||
@@ -50,9 +54,13 @@ type Agent struct {
|
||||
http *http.Client
|
||||
stream *http.Client
|
||||
state state
|
||||
stateMu sync.Mutex
|
||||
hosts hostState
|
||||
pollCount int
|
||||
|
||||
// Direct tunnel currently running (see setTunnelAddr).
|
||||
transport atomic.Value // string: auto, ws or lp, as the server says
|
||||
link atomic.Pointer[wsLink]
|
||||
tunMu sync.Mutex
|
||||
tunAddr string
|
||||
tunCancel context.CancelFunc
|
||||
@@ -94,11 +102,20 @@ func (a *Agent) Run(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
if a.state.AgentID == "" || a.state.Token == "" {
|
||||
if err := a.enroll(ctx); err != nil {
|
||||
err := a.enroll(ctx)
|
||||
// The main address out of reach at install: the direct one.
|
||||
if err != nil && a.cfg.Fallback != "" && a.cfg.Fallback != a.cfg.Host {
|
||||
log.Printf("enroll at %s: %v - trying %s", a.cfg.Host, err, a.cfg.Fallback)
|
||||
a.setHost(a.cfg.Fallback)
|
||||
a.hostChanged()
|
||||
err = a.enroll(ctx)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("enroll: %w", err)
|
||||
}
|
||||
}
|
||||
log.Printf("agent %s connected to %s", a.state.AgentID, a.cfg.Host)
|
||||
go a.watchMain(ctx)
|
||||
log.Printf("agent %s connected to %s", a.state.AgentID, a.host())
|
||||
|
||||
// Direct forward tunnel (low-latency TCP mux + UDP). Its address comes
|
||||
// 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)
|
||||
}
|
||||
|
||||
// The control channel: a WebSocket, or long polls. In "auto" a failed or
|
||||
// dropped WebSocket gives way to long polling for a minute, then is tried
|
||||
// again; "ws" keeps trying the WebSocket; "lp" only polls.
|
||||
backoff := time.Second
|
||||
for ctx.Err() == nil {
|
||||
if err := a.pollOnce(ctx); err != nil {
|
||||
log.Printf("poll: %v (retry in %s)", err, backoff)
|
||||
if a.mode() == proto.TransportLP {
|
||||
a.longPoll(ctx, 0)
|
||||
continue
|
||||
}
|
||||
err := a.runWS(ctx)
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
// Moved to the other address: the WebSocket goes there at once.
|
||||
if a.hostChanged() {
|
||||
backoff = time.Second
|
||||
continue
|
||||
}
|
||||
a.failed()
|
||||
if a.hostChanged() {
|
||||
continue
|
||||
}
|
||||
switch a.mode() {
|
||||
case proto.TransportWS:
|
||||
log.Printf("websocket: %v (retry in %s)", err, backoff)
|
||||
select {
|
||||
case <-time.After(backoff):
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
}
|
||||
if backoff < 15*time.Second {
|
||||
backoff *= 2
|
||||
}
|
||||
backoff = min(backoff*2, retryCap)
|
||||
continue
|
||||
case proto.TransportAuto:
|
||||
log.Printf("websocket: %v - long polling, websocket again in a minute", err)
|
||||
}
|
||||
backoff = time.Second
|
||||
a.longPoll(ctx, time.Minute)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// mode is the transport the server set for this agent.
|
||||
func (a *Agent) mode() string {
|
||||
if v, ok := a.transport.Load().(string); ok {
|
||||
return v
|
||||
}
|
||||
return proto.TransportAuto
|
||||
}
|
||||
|
||||
func (a *Agent) applyTransport(v string) {
|
||||
switch v {
|
||||
case proto.TransportAuto, proto.TransportWS, proto.TransportLP:
|
||||
if v != a.mode() {
|
||||
log.Printf("transport: %s", v)
|
||||
a.transport.Store(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// longPoll runs control polls: for d, or with d zero until the transport is
|
||||
// no longer long polling.
|
||||
func (a *Agent) longPoll(ctx context.Context, d time.Duration) {
|
||||
until := time.Now().Add(d)
|
||||
backoff := time.Second
|
||||
// The server was out of reach (a network down, a machine asleep): once
|
||||
// a poll gets through again, "auto" tries the WebSocket at once rather
|
||||
// than at the end of the minute - it was the network, not the WebSocket.
|
||||
lost := false
|
||||
for ctx.Err() == nil {
|
||||
if d > 0 && (time.Now().After(until) || a.mode() == proto.TransportWS) {
|
||||
return
|
||||
}
|
||||
if d == 0 && a.mode() != proto.TransportLP {
|
||||
return
|
||||
}
|
||||
// Back from out of reach in "auto": a quick request tells when the
|
||||
// server answers again, and the WebSocket is tried then; a poll
|
||||
// would sit held on the server first.
|
||||
if lost && d > 0 {
|
||||
if a.reachable(ctx) {
|
||||
a.reached()
|
||||
return
|
||||
}
|
||||
a.failed()
|
||||
if a.hostChanged() {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-time.After(backoff):
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
backoff = min(backoff*2, retryCap)
|
||||
continue
|
||||
}
|
||||
// In "auto" the minute is a minute: a poll still held then is cut
|
||||
// short (the server keeps its job for the next one).
|
||||
reqCtx, cancel := ctx, context.CancelFunc(func() {})
|
||||
if d > 0 {
|
||||
reqCtx, cancel = context.WithDeadline(ctx, until)
|
||||
}
|
||||
err := a.pollOnce(ctx, reqCtx)
|
||||
cut := reqCtx.Err() == context.DeadlineExceeded
|
||||
cancel()
|
||||
if err != nil && cut && ctx.Err() == nil {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
a.failed()
|
||||
if a.hostChanged() {
|
||||
return
|
||||
}
|
||||
log.Printf("poll: %v (retry in %s)", err, backoff)
|
||||
select {
|
||||
case <-time.After(backoff):
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
backoff = min(backoff*2, retryCap)
|
||||
lost = true
|
||||
continue
|
||||
}
|
||||
a.reached()
|
||||
backoff = time.Second
|
||||
}
|
||||
}
|
||||
|
||||
// reachable reports whether the server answers a quick request.
|
||||
func (a *Agent) reachable(ctx context.Context) bool {
|
||||
return a.reachableAt(ctx, a.host())
|
||||
}
|
||||
|
||||
// retryCap bounds the wait between attempts to reach the server, so a
|
||||
// machine back from sleep or a network back up is reconnected in seconds.
|
||||
const retryCap = 5 * time.Second
|
||||
|
||||
func (a *Agent) enroll(ctx context.Context) error {
|
||||
host, _ := os.Hostname()
|
||||
body, _ := json.Marshal(proto.EnrollRequest{
|
||||
@@ -136,7 +270,7 @@ func (a *Agent) enroll(ctx context.Context) error {
|
||||
Version: Version,
|
||||
User: currentUser(),
|
||||
})
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, a.cfg.Host+"/api/agent/enroll", bytes.NewReader(body))
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, a.host()+"/api/agent/enroll", bytes.NewReader(body))
|
||||
req.Header.Set("X-Enroll-Secret", a.cfg.EnrollSecret)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := a.http.Do(req)
|
||||
@@ -157,8 +291,10 @@ func (a *Agent) enroll(ctx context.Context) error {
|
||||
}
|
||||
|
||||
// pollOnce performs one control long-poll and dispatches any instruction.
|
||||
func (a *Agent) pollOnce(ctx context.Context) error {
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, a.cfg.Host+"/api/agent/poll", nil)
|
||||
// The poll itself runs on reqCtx, which may end early; what it starts runs on
|
||||
// ctx.
|
||||
func (a *Agent) pollOnce(ctx, reqCtx context.Context) error {
|
||||
req, _ := http.NewRequestWithContext(reqCtx, http.MethodGet, a.host()+"/api/agent/poll", nil)
|
||||
a.authHeaders(req)
|
||||
// Report the version and our OS user occasionally (not every poll), so the
|
||||
// panel can flag outdated agents without extra traffic.
|
||||
@@ -183,16 +319,27 @@ func (a *Agent) pollOnce(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
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 {
|
||||
case proto.CtrlOpenTerminal:
|
||||
go a.runTerminal(ctx, msg.SessionID, msg.User)
|
||||
go a.runTerminal(ctx, msg.SessionID, msg.User, msg.Dir)
|
||||
case proto.CtrlOpenForward:
|
||||
go a.runForward(ctx, msg.SessionID, msg.Port)
|
||||
case proto.CtrlTask:
|
||||
go a.runTask(ctx, msg.TaskID)
|
||||
case proto.CtrlOpenSSH:
|
||||
if msg.SSH != nil {
|
||||
go a.runSSH(ctx, msg.SessionID, *msg.SSH)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fetchConfig asks the server for the settings it hands to agents and reports
|
||||
@@ -200,20 +347,25 @@ func (a *Agent) pollOnce(ctx context.Context) error {
|
||||
func (a *Agent) fetchConfig(ctx context.Context) bool {
|
||||
ctx2, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
req, _ := http.NewRequestWithContext(ctx2, http.MethodGet, a.cfg.Host+"/api/agent/config", nil)
|
||||
req, _ := http.NewRequestWithContext(ctx2, http.MethodGet, a.host()+"/api/agent/config", nil)
|
||||
a.authHeaders(req)
|
||||
resp, err := a.http.Do(req)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
a.applyTransport(resp.Header.Get(proto.TransportHeader))
|
||||
a.learnAddrs(resp.Header.Get(proto.MainHeader), resp.Header.Get(proto.FallbackHeader))
|
||||
return a.applyTunnelHeader(ctx, resp.Header)
|
||||
}
|
||||
|
||||
// applyTunnelHeader follows the tunnel address the server advertises as
|
||||
// "host:port"; an empty host means the host we already reach the server on.
|
||||
func (a *Agent) applyTunnelHeader(ctx context.Context, h http.Header) bool {
|
||||
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 {
|
||||
return false
|
||||
}
|
||||
@@ -222,7 +374,7 @@ func (a *Agent) applyTunnelHeader(ctx context.Context, h http.Header) bool {
|
||||
return false
|
||||
}
|
||||
if host == "" {
|
||||
u, err := url.Parse(a.cfg.Host)
|
||||
u, err := url.Parse(a.host())
|
||||
if err != nil || u.Hostname() == "" {
|
||||
return false
|
||||
}
|
||||
@@ -277,7 +429,9 @@ func (a *Agent) saveState() error {
|
||||
if err := os.MkdirAll(filepath.Dir(a.cfg.StatePath), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
a.stateMu.Lock()
|
||||
b, _ := json.MarshalIndent(a.state, "", " ")
|
||||
a.stateMu.Unlock()
|
||||
return os.WriteFile(a.cfg.StatePath, b, 0o600)
|
||||
}
|
||||
|
||||
|
||||
+22
-2
@@ -72,6 +72,18 @@ func dispatchFS(env proto.TaskEnvelope) (any, error) {
|
||||
var a proto.FSZipArgs
|
||||
_ = json.Unmarshal(env.Args, &a)
|
||||
return fsZip(a.Paths, a.Dest)
|
||||
case proto.TaskFSStat:
|
||||
var a proto.FSPathArgs
|
||||
_ = json.Unmarshal(env.Args, &a)
|
||||
return fsStat(a.Path)
|
||||
case proto.TaskFSChmod:
|
||||
var a proto.FSChmodArgs
|
||||
_ = json.Unmarshal(env.Args, &a)
|
||||
return fsChmod(a)
|
||||
case proto.TaskFSCopy:
|
||||
var a proto.FSCopyArgs
|
||||
_ = json.Unmarshal(env.Args, &a)
|
||||
return fsCopy(a)
|
||||
default:
|
||||
return nil, fmt.Errorf("неизвестная операция %q", env.Type)
|
||||
}
|
||||
@@ -102,13 +114,21 @@ func fsList(path string) (proto.FSListResult, error) {
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, proto.FSEntry{
|
||||
ent := proto.FSEntry{
|
||||
Name: e.Name(),
|
||||
IsDir: e.IsDir(),
|
||||
Size: info.Size(),
|
||||
Mode: info.Mode().String(),
|
||||
MTime: info.ModTime().Unix(),
|
||||
})
|
||||
}
|
||||
// A link to a folder opens like one.
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
ent.Link = true
|
||||
if t, err := os.Stat(filepath.Join(abs, e.Name())); err == nil && t.IsDir() {
|
||||
ent.IsDir = true
|
||||
}
|
||||
}
|
||||
out = append(out, ent)
|
||||
}
|
||||
// Directories first, then alphabetical.
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||
)
|
||||
|
||||
// fsCopy copies or moves Src to Dst, following Conflict when Dst exists.
|
||||
func fsCopy(a proto.FSCopyArgs) (any, error) {
|
||||
src, dst := filepath.Clean(a.Src), filepath.Clean(a.Dst)
|
||||
if a.Src == "" || a.Dst == "" {
|
||||
return nil, errors.New("не указан путь")
|
||||
}
|
||||
if src == dst {
|
||||
return nil, errors.New("откуда и куда - одно и то же")
|
||||
}
|
||||
if strings.HasPrefix(dst, src+string(filepath.Separator)) {
|
||||
return nil, errors.New("нельзя положить папку внутрь неё самой")
|
||||
}
|
||||
if _, err := os.Lstat(src); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := os.Stat(filepath.Dir(dst)); err != nil {
|
||||
return nil, fmt.Errorf("нет папки %s", filepath.Dir(dst))
|
||||
}
|
||||
c := &copier{move: a.Move, conflict: a.Conflict}
|
||||
if err := c.place(src, dst); err != nil {
|
||||
return map[string]int{"done": c.done, "skipped": c.skipped}, err
|
||||
}
|
||||
return map[string]int{"done": c.done, "skipped": c.skipped}, nil
|
||||
}
|
||||
|
||||
type copier struct {
|
||||
move bool
|
||||
conflict string
|
||||
done, skipped int
|
||||
}
|
||||
|
||||
// place puts src at dst. Two folders merge when the rule allows it; anything
|
||||
// else that is in the way stops, is replaced or is kept, by the rule.
|
||||
func (c *copier) place(src, dst string) error {
|
||||
si, err := os.Lstat(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
di, err := os.Lstat(dst)
|
||||
if err == nil {
|
||||
if c.conflict != "replace" && c.conflict != "skip" {
|
||||
return fmt.Errorf("%s уже существует", dst)
|
||||
}
|
||||
if si.IsDir() && di.IsDir() {
|
||||
entries, err := os.ReadDir(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if err := c.place(filepath.Join(src, e.Name()), filepath.Join(dst, e.Name())); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if c.move {
|
||||
// Whatever was skipped stays where it was.
|
||||
_ = os.Remove(src)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if c.conflict == "skip" {
|
||||
c.skipped++
|
||||
return nil
|
||||
}
|
||||
if err := os.RemoveAll(dst); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if !errors.Is(err, fs.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
if c.move {
|
||||
err := os.Rename(src, dst)
|
||||
if err == nil {
|
||||
c.done++
|
||||
return nil
|
||||
}
|
||||
// Another file system: copy, then take the original away.
|
||||
if !errors.Is(err, syscall.EXDEV) {
|
||||
return err
|
||||
}
|
||||
if err := c.copyTree(src, dst); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.RemoveAll(src)
|
||||
}
|
||||
return c.copyTree(src, dst)
|
||||
}
|
||||
|
||||
// copyTree copies a file, a symlink or a whole folder, keeping modes and
|
||||
// times.
|
||||
func (c *copier) copyTree(src, dst string) error {
|
||||
return filepath.WalkDir(src, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, _ := filepath.Rel(src, p)
|
||||
to := filepath.Join(dst, rel)
|
||||
info, err := os.Lstat(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch {
|
||||
case info.Mode()&fs.ModeSymlink != 0:
|
||||
link, err := os.Readlink(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Symlink(link, to); err != nil {
|
||||
return err
|
||||
}
|
||||
case info.IsDir():
|
||||
if err := os.Mkdir(to, info.Mode().Perm()); err != nil && !errors.Is(err, fs.ErrExist) {
|
||||
return err
|
||||
}
|
||||
case info.Mode().IsRegular():
|
||||
if err := copyFile(p, to, info); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return nil // sockets, devices: not copied
|
||||
}
|
||||
c.done++
|
||||
if st, ok := info.Sys().(*syscall.Stat_t); ok {
|
||||
_ = os.Lchown(to, int(st.Uid), int(st.Gid))
|
||||
}
|
||||
if info.Mode()&fs.ModeSymlink == 0 {
|
||||
_ = os.Chtimes(to, info.ModTime(), info.ModTime())
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func copyFile(src, dst string, info fs.FileInfo) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode().Perm())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
out.Close()
|
||||
os.Remove(dst)
|
||||
return err
|
||||
}
|
||||
if err := out.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Chmod(dst, info.Mode()&(fs.ModePerm|fs.ModeSetuid|fs.ModeSetgid|fs.ModeSticky))
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||
)
|
||||
|
||||
// fsStat reports a file's properties, following a symlink to what it points
|
||||
// at, and the user and group names to choose an owner from.
|
||||
func fsStat(path string) (proto.FSStatResult, error) {
|
||||
res := proto.FSStatResult{Path: path}
|
||||
if li, err := os.Lstat(path); err == nil && li.Mode()&os.ModeSymlink != 0 {
|
||||
res.Link, _ = os.Readlink(path)
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
res.IsDir, res.Size, res.MTime = info.IsDir(), info.Size(), info.ModTime().Unix()
|
||||
if st, ok := info.Sys().(*syscall.Stat_t); ok {
|
||||
res.Mode = uint32(st.Mode) & 07777
|
||||
res.UID, res.GID = int(st.Uid), int(st.Gid)
|
||||
} else {
|
||||
res.Mode = uint32(info.Mode().Perm())
|
||||
}
|
||||
res.Owner, res.Group = strconv.Itoa(res.UID), strconv.Itoa(res.GID)
|
||||
if u, err := user.LookupId(res.Owner); err == nil {
|
||||
res.Owner = u.Username
|
||||
}
|
||||
if g, err := user.LookupGroupId(res.Group); err == nil {
|
||||
res.Group = g.Name
|
||||
}
|
||||
res.Users, res.Groups = namesIn("/etc/passwd"), namesIn("/etc/group")
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// namesIn lists the first field of a passwd- or group-style file.
|
||||
func namesIn(file string) []string {
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer f.Close()
|
||||
var out []string
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() && len(out) < 1000 {
|
||||
line := sc.Text()
|
||||
if line == "" || line[0] == '#' {
|
||||
continue
|
||||
}
|
||||
if name, _, ok := strings.Cut(line, ":"); ok && name != "" {
|
||||
out = append(out, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// lookupID resolves a user or group given by name or number; -1 for none.
|
||||
func lookupID(name string, group bool) (int, error) {
|
||||
if name == "" {
|
||||
return -1, nil
|
||||
}
|
||||
if n, err := strconv.Atoi(name); err == nil && n >= 0 {
|
||||
return n, nil
|
||||
}
|
||||
if group {
|
||||
g, err := user.LookupGroup(name)
|
||||
if err != nil {
|
||||
return -1, fmt.Errorf("группа %s не найдена", name)
|
||||
}
|
||||
return strconv.Atoi(g.Gid)
|
||||
}
|
||||
u, err := user.Lookup(name)
|
||||
if err != nil {
|
||||
return -1, fmt.Errorf("пользователь %s не найден", name)
|
||||
}
|
||||
return strconv.Atoi(u.Uid)
|
||||
}
|
||||
|
||||
// fsChmod sets mode, owner and group on a path, and with Recursive on all
|
||||
// that is inside a folder. Symlinks inside are not followed.
|
||||
func fsChmod(a proto.FSChmodArgs) (any, error) {
|
||||
if a.Path == "" {
|
||||
return nil, errors.New("не указан путь")
|
||||
}
|
||||
if a.Recursive && filepath.Clean(a.Path) == "/" {
|
||||
return nil, errors.New("отказ: рекурсивно для корня нельзя")
|
||||
}
|
||||
if a.Mode != nil && *a.Mode > 07777 {
|
||||
return nil, errors.New("неверные права")
|
||||
}
|
||||
uid, err := lookupID(a.Owner, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gid, err := lookupID(a.Group, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
changed, failed := 0, 0
|
||||
var errs []string
|
||||
// The path itself always gets the change; Only picks among what is in it.
|
||||
apply := func(p string, isDir, isLink, self bool) {
|
||||
if !self && ((a.Only == "files" && isDir) || (a.Only == "dirs" && !isDir)) {
|
||||
return
|
||||
}
|
||||
var err error
|
||||
if uid >= 0 || gid >= 0 {
|
||||
err = os.Lchown(p, uid, gid)
|
||||
}
|
||||
// A symlink has no mode of its own: chmod would change its target.
|
||||
if err == nil && a.Mode != nil && !isLink {
|
||||
err = syscall.Chmod(p, *a.Mode)
|
||||
}
|
||||
if err != nil {
|
||||
failed++
|
||||
if len(errs) < 5 {
|
||||
errs = append(errs, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
changed++
|
||||
}
|
||||
info, err := os.Stat(a.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !a.Recursive || !info.IsDir() {
|
||||
apply(a.Path, info.IsDir(), false, true)
|
||||
} else {
|
||||
err = filepath.WalkDir(a.Path, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
failed++
|
||||
if len(errs) < 5 {
|
||||
errs = append(errs, err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
apply(p, d.IsDir(), d.Type()&fs.ModeSymlink != 0, p == a.Path)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
res := map[string]any{"changed": changed, "failed": failed, "errors": errs}
|
||||
if failed > 0 && changed == 0 {
|
||||
return res, errors.New(errs[0])
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
@@ -41,7 +41,7 @@ type errXferGone string
|
||||
func (e errXferGone) Error() string { return string(e) }
|
||||
|
||||
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.
|
||||
@@ -289,3 +289,49 @@ func (a *Agent) doFSSend(ctx context.Context, env proto.TaskEnvelope) proto.Task
|
||||
data, _ := json.Marshal(map[string]any{"path": args.Path, "size": size})
|
||||
return proto.TaskResult{OK: true, Data: data}
|
||||
}
|
||||
|
||||
// ordered fetches pieces 0..n-1 with up to depth in flight and hands them to
|
||||
// use one by one in their order: a stream that must be written in sequence
|
||||
// (stdin over SSH) still gets its pieces fetched ahead.
|
||||
func ordered(ctx context.Context, n, depth int, fetch func(ctx context.Context, i int) ([]byte, error), use func(i int, b []byte) error) error {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
type got struct {
|
||||
b []byte
|
||||
err error
|
||||
}
|
||||
slots := make([]chan got, n)
|
||||
for i := range slots {
|
||||
slots[i] = make(chan got, 1)
|
||||
}
|
||||
sem := make(chan struct{}, depth)
|
||||
go func() {
|
||||
for i := 0; i < n; i++ {
|
||||
select {
|
||||
case sem <- struct{}{}:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
go func(i int) {
|
||||
b, err := fetch(ctx, i)
|
||||
slots[i] <- got{b, err}
|
||||
}(i)
|
||||
}
|
||||
}()
|
||||
for i := 0; i < n; i++ {
|
||||
var g got
|
||||
select {
|
||||
case g = <-slots[i]:
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
<-sem
|
||||
if g.err != nil {
|
||||
return g.err
|
||||
}
|
||||
if err := use(i, g.b); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math/rand"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Pieces fetched side by side are used in their order, never more than
|
||||
// depth at once in flight.
|
||||
func TestOrderedKeepsOrder(t *testing.T) {
|
||||
var inFlight, most int32
|
||||
var got []int
|
||||
err := ordered(context.Background(), 20, 3, func(ctx context.Context, i int) ([]byte, error) {
|
||||
n := atomic.AddInt32(&inFlight, 1)
|
||||
for {
|
||||
m := atomic.LoadInt32(&most)
|
||||
if n <= m || atomic.CompareAndSwapInt32(&most, m, n) {
|
||||
break
|
||||
}
|
||||
}
|
||||
time.Sleep(time.Duration(rand.Intn(5)) * time.Millisecond)
|
||||
atomic.AddInt32(&inFlight, -1)
|
||||
return []byte{byte(i)}, nil
|
||||
}, func(i int, b []byte) error {
|
||||
if int(b[0]) != i {
|
||||
t.Errorf("piece %d carries %d", i, b[0])
|
||||
}
|
||||
got = append(got, i)
|
||||
return nil
|
||||
})
|
||||
if err != nil || len(got) != 20 {
|
||||
t.Fatalf("got %v, %v", got, err)
|
||||
}
|
||||
for i, v := range got {
|
||||
if v != i {
|
||||
t.Fatalf("order: %v", got)
|
||||
}
|
||||
}
|
||||
if most > 3 || most < 2 {
|
||||
t.Fatalf("at most %d in flight", most)
|
||||
}
|
||||
}
|
||||
@@ -16,8 +16,8 @@ import (
|
||||
|
||||
// runTerminal spawns a PTY and bridges it to the session.
|
||||
// user is the OS user to run the shell as ("" = the agent's own user).
|
||||
func (a *Agent) runTerminal(parent context.Context, sid, user string) {
|
||||
term, err := StartPTY(user)
|
||||
func (a *Agent) runTerminal(parent context.Context, sid, user, dir string) {
|
||||
term, err := StartPTY(user, dir)
|
||||
if err != nil {
|
||||
log.Printf("term %s: start pty: %v", sid, err)
|
||||
// Show the reason in the browser terminal before closing it.
|
||||
@@ -26,6 +26,8 @@ func (a *Agent) runTerminal(parent context.Context, sid, user string) {
|
||||
return
|
||||
}
|
||||
log.Printf("term %s: shell started (user %q)", sid, user)
|
||||
termsBySID.Store(sid, term)
|
||||
defer termsBySID.Delete(sid)
|
||||
a.bridgeStream(parent, sid, term, term.Resize)
|
||||
log.Printf("term %s: ended", sid)
|
||||
}
|
||||
@@ -54,6 +56,15 @@ func (a *Agent) bridgeStream(parent context.Context, sid string, rw io.ReadWrite
|
||||
ctx, cancel := context.WithCancel(parent)
|
||||
defer cancel()
|
||||
defer rw.Close()
|
||||
defer func() {
|
||||
if l := a.link.Load(); l != nil {
|
||||
l.unsubscribe(sid)
|
||||
}
|
||||
}()
|
||||
// A session closed from the panel (a tab reloaded, a window shut) ends
|
||||
// the stream at once, not on its next output: an idle shell would live
|
||||
// on until the agent stopped.
|
||||
go func() { <-ctx.Done(); rw.Close() }()
|
||||
|
||||
// Downstream: pull input frames and write them to the stream.
|
||||
go func() {
|
||||
@@ -84,7 +95,22 @@ func (a *Agent) bridgeStream(parent context.Context, sid string, rw io.ReadWrite
|
||||
// pollIn performs one downstream long-poll and applies the frames.
|
||||
// Returns false when the session is gone and the loop should stop.
|
||||
func (a *Agent) pollIn(ctx context.Context, sid string, w io.Writer, onResize func(cols, rows uint16)) bool {
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, a.cfg.Host+"/api/agent/term/"+sid+"/in", nil)
|
||||
// On the WebSocket the input comes pushed; if it drops, by a long poll.
|
||||
if l := a.link.Load(); l != nil {
|
||||
if ch := l.subscribe(sid); ch != nil {
|
||||
select {
|
||||
case in := <-ch:
|
||||
if in.gone {
|
||||
return false
|
||||
}
|
||||
return applyFrames(in.frames, w, onResize)
|
||||
case <-l.done:
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, a.host()+"/api/agent/term/"+sid+"/in", nil)
|
||||
a.authHeaders(req)
|
||||
resp, err := a.http.Do(req)
|
||||
if err != nil {
|
||||
@@ -101,7 +127,13 @@ func (a *Agent) pollIn(ctx context.Context, sid string, w io.Writer, onResize fu
|
||||
if err := json.NewDecoder(resp.Body).Decode(&batch); err != nil {
|
||||
return true
|
||||
}
|
||||
for _, f := range batch.Frames {
|
||||
return applyFrames(batch.Frames, w, onResize)
|
||||
}
|
||||
|
||||
// applyFrames writes a session's input to its stream; false once the panel
|
||||
// closed it.
|
||||
func applyFrames(frames []proto.InFrame, w io.Writer, onResize func(cols, rows uint16)) bool {
|
||||
for _, f := range frames {
|
||||
switch f.Type {
|
||||
case proto.FrameStdin:
|
||||
if data, err := b64.DecodeString(f.Data); err == nil {
|
||||
@@ -120,8 +152,13 @@ func (a *Agent) pollIn(ctx context.Context, sid string, w io.Writer, onResize fu
|
||||
|
||||
// postOut sends one upstream message. Returns false if the session is gone.
|
||||
func (a *Agent) postOut(ctx context.Context, sid string, msg proto.OutMessage) bool {
|
||||
if l := a.link.Load(); l != nil {
|
||||
if l.send(proto.WSMsg{T: "out", SID: sid, Out: &msg}) == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
body, _ := json.Marshal(msg)
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, a.cfg.Host+"/api/agent/term/"+sid+"/out", bytes.NewReader(body))
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, a.host()+"/api/agent/term/"+sid+"/out", bytes.NewReader(body))
|
||||
a.authHeaders(req)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := a.http.Do(req)
|
||||
|
||||
@@ -0,0 +1,675 @@
|
||||
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 {
|
||||
return packTars(ctx, w, []shipItem{{src, name}}, m)
|
||||
}
|
||||
|
||||
// shipItem is one thing sent: where it is and the name it goes under.
|
||||
type shipItem struct{ src, name string }
|
||||
|
||||
// packTars writes the items as one tar stream, each under its name.
|
||||
func packTars(ctx context.Context, w io.Writer, items []shipItem, m *shipMeter) error {
|
||||
tw := tar.NewWriter(w)
|
||||
for _, it := range items {
|
||||
if err := packInto(ctx, tw, it.src, it.name, m); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tw.Close()
|
||||
}
|
||||
|
||||
func packInto(ctx context.Context, tw *tar.Writer, src, name string, m *shipMeter) error {
|
||||
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
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
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()}
|
||||
}
|
||||
names := args.ShipNames()
|
||||
for _, n := range names {
|
||||
if err := shipName(n); err != nil {
|
||||
return proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
}
|
||||
if len(args.More) > 0 && args.SSH != nil {
|
||||
return proto.TaskResult{Error: "по SSH-адресу - по одному"}
|
||||
}
|
||||
src := filepath.Clean(args.Src)
|
||||
srcs := []string{args.Src}
|
||||
for _, p := range args.More {
|
||||
// The others: of the same folder only.
|
||||
if path.Dir(path.Clean(p)) != path.Dir(path.Clean(args.Src)) {
|
||||
return proto.TaskResult{Error: "несколько - только из одной папки"}
|
||||
}
|
||||
srcs = append(srcs, p)
|
||||
}
|
||||
var conn *sshfsConn
|
||||
if args.Conn != "" {
|
||||
// The files are on an SSH host this agent holds a connection to.
|
||||
c, err := sshfsGet(args.Conn)
|
||||
if err != nil {
|
||||
return proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
conn, src = c, path.Clean(args.Src)
|
||||
} else 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)
|
||||
var total int64
|
||||
items := make([]shipItem, len(srcs))
|
||||
for i, p := range srcs {
|
||||
if conn != nil {
|
||||
total += a.remoteSize(conn, path.Clean(p))
|
||||
} else {
|
||||
total += treeSize(ctx, filepath.Clean(p))
|
||||
}
|
||||
items[i] = shipItem{filepath.Clean(p), names[i]}
|
||||
}
|
||||
m.total.Store(total)
|
||||
pack := func(w io.Writer) error { return packTars(ctx, w, items, m) }
|
||||
if conn != nil {
|
||||
pack = func(w io.Writer) error { return a.packRemote(ctx, w, conn, srcs, names, m) }
|
||||
}
|
||||
|
||||
var err error
|
||||
if args.SSH != nil {
|
||||
err = a.shipSSH(ctx, args, src, m)
|
||||
} else {
|
||||
m.note.Store("")
|
||||
err = a.shipRelay(ctx, args, pack)
|
||||
}
|
||||
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, pack func(io.Writer) error) 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(pack(pw)) }()
|
||||
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()}
|
||||
}
|
||||
names := args.ShipNames()
|
||||
for _, n := range names {
|
||||
if err := shipName(n); err != nil {
|
||||
return proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
}
|
||||
dir := filepath.Clean(args.Dir)
|
||||
var conn *sshfsConn
|
||||
if args.Conn != "" {
|
||||
// Into an SSH host this agent holds a connection to.
|
||||
c, err := sshfsGet(args.Conn)
|
||||
if err != nil {
|
||||
return proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
conn, dir = c, path.Clean(args.Dir)
|
||||
} else {
|
||||
for _, n := range names {
|
||||
if _, err := os.Lstat(filepath.Join(dir, n)); err == nil && args.Conflict != "replace" && args.Conflict != "skip" {
|
||||
return proto.TaskResult{Error: filepath.Join(dir, n) + " уже существует"}
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}()
|
||||
if conn != nil {
|
||||
skipped, err := a.unpackRemote(conn, pr, dir, names, args.Conflict)
|
||||
if err == nil {
|
||||
_, _ = io.Copy(io.Discard, pr)
|
||||
}
|
||||
pr.CloseWithError(errors.New("распаковка закончена"))
|
||||
if err != nil {
|
||||
return proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
res := map[string]int{}
|
||||
if skipped > 0 {
|
||||
res["skipped"] = skipped
|
||||
}
|
||||
data, _ := json.Marshal(res)
|
||||
return proto.TaskResult{OK: true, Data: data}
|
||||
}
|
||||
allowed := map[string]bool{}
|
||||
for _, n := range names {
|
||||
allowed[n] = true
|
||||
}
|
||||
u := &unpacker{dir: dir, names: allowed, 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, conflict string
|
||||
names map[string]bool // the top entries the stream may carry
|
||||
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)
|
||||
top, _, _ := strings.Cut(clean, "/")
|
||||
if !u.names[top] || clean == ".." || strings.HasPrefix(clean, "../") {
|
||||
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, "'", `'\''`) + "'" }
|
||||
|
||||
// posix wraps a command for the host's sh: the user's login shell gets one
|
||||
// word to hand on, so fish or csh there does not matter.
|
||||
func posix(cmd string) string {
|
||||
// csh reads "!" as history even inside single quotes; '\!' is a plain
|
||||
// "!" for it and for sh alike.
|
||||
return "sh -c " + strings.ReplaceAll(shq(cmd), "!", `'\!'`)
|
||||
}
|
||||
|
||||
// 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(posix(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(posix(cmd))
|
||||
var ee *ssh.ExitError
|
||||
if errors.As(err, &ee) {
|
||||
err = nil // the checks may exit non-zero; what they print is the answer
|
||||
}
|
||||
return string(out), err
|
||||
}
|
||||
|
||||
type limitBuf struct {
|
||||
b *bytes.Buffer
|
||||
max int
|
||||
}
|
||||
|
||||
func (l *limitBuf) Write(p []byte) (int, error) {
|
||||
if room := l.max - l.b.Len(); room > 0 {
|
||||
l.b.Write(p[:min(len(p), room)])
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Several items of one folder go as one tar and land side by side; an entry
|
||||
// of none of them is refused.
|
||||
func TestShipSeveralInOneTar(t *testing.T) {
|
||||
src, dst := t.TempDir(), t.TempDir()
|
||||
_ = os.WriteFile(filepath.Join(src, "a.txt"), []byte("A"), 0o644)
|
||||
_ = os.MkdirAll(filepath.Join(src, "b"), 0o755)
|
||||
_ = os.WriteFile(filepath.Join(src, "b", "c.txt"), []byte("C"), 0o644)
|
||||
var buf bytes.Buffer
|
||||
items := []shipItem{{filepath.Join(src, "a.txt"), "a.txt"}, {filepath.Join(src, "b"), "b"}}
|
||||
if err := packTars(context.Background(), &buf, items, &shipMeter{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u := &unpacker{dir: dst, names: map[string]bool{"a.txt": true, "b": true}}
|
||||
if err := u.unpack(tar.NewReader(&buf)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if b, _ := os.ReadFile(filepath.Join(dst, "b", "c.txt")); string(b) != "C" {
|
||||
t.Fatalf("b/c.txt: %q", b)
|
||||
}
|
||||
if b, _ := os.ReadFile(filepath.Join(dst, "a.txt")); string(b) != "A" {
|
||||
t.Fatalf("a.txt: %q", b)
|
||||
}
|
||||
buf.Reset()
|
||||
_ = packTars(context.Background(), &buf, items, &shipMeter{})
|
||||
u = &unpacker{dir: t.TempDir(), names: map[string]bool{"a.txt": true}}
|
||||
if err := u.unpack(tar.NewReader(&buf)); err == nil || !strings.Contains(err.Error(), "чужой путь") {
|
||||
t.Fatalf("b let in: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenameTarSeveral(t *testing.T) {
|
||||
var src bytes.Buffer
|
||||
tw := tar.NewWriter(&src)
|
||||
for _, n := range []string{"a", "b/x", "c", "._a"} {
|
||||
_ = tw.WriteHeader(&tar.Header{Name: n, Mode: 0o644, Typeflag: tar.TypeReg})
|
||||
}
|
||||
_ = tw.Close()
|
||||
var out bytes.Buffer
|
||||
if err := renameTar(&out, &src, map[string]string{"a": "a", "b": "b2"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tr := tar.NewReader(&out)
|
||||
var got []string
|
||||
for {
|
||||
h, err := tr.Next()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
got = append(got, h.Name)
|
||||
}
|
||||
if strings.Join(got, ",") != "a,b2/x" {
|
||||
t.Fatalf("entries: %v", got)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
req, err := http.NewRequestWithContext(ctx, method, a.cfg.Host+path, body)
|
||||
req, err := http.NewRequestWithContext(ctx, method, a.host()+path, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,512 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||
)
|
||||
|
||||
// SQLite databases are read and changed here, through SQLite itself, so a
|
||||
// database that is in use keeps its locks and journal: a value is set with an
|
||||
// UPDATE, not by writing the file over.
|
||||
|
||||
// dbCell is one value as the panel shows and sends it: t is "i" (integer),
|
||||
// "r" (real), "s" (text), "b" (blob, v its size, not editable) or "n" (NULL).
|
||||
type dbCell struct {
|
||||
T string `json:"t"`
|
||||
V string `json:"v,omitempty"`
|
||||
}
|
||||
|
||||
func cellOf(v any) dbCell {
|
||||
switch x := v.(type) {
|
||||
case nil:
|
||||
return dbCell{T: "n"}
|
||||
case int64:
|
||||
return dbCell{T: "i", V: strconv.FormatInt(x, 10)}
|
||||
case float64:
|
||||
return dbCell{T: "r", V: strconv.FormatFloat(x, 'g', -1, 64)}
|
||||
case string:
|
||||
return dbCell{T: "s", V: x}
|
||||
case []byte:
|
||||
return dbCell{T: "b", V: strconv.Itoa(len(x))}
|
||||
case bool:
|
||||
if x {
|
||||
return dbCell{T: "i", V: "1"}
|
||||
}
|
||||
return dbCell{T: "i", V: "0"}
|
||||
case time.Time:
|
||||
return dbCell{T: "s", V: x.Format(time.RFC3339Nano)}
|
||||
}
|
||||
return dbCell{T: "s", V: fmt.Sprint(v)}
|
||||
}
|
||||
|
||||
// value is what a cell becomes for SQLite.
|
||||
func (c dbCell) value() (any, error) {
|
||||
switch c.T {
|
||||
case "n":
|
||||
return nil, nil
|
||||
case "i":
|
||||
n, err := strconv.ParseInt(strings.TrimSpace(c.V), 10, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%q - не целое число", c.V)
|
||||
}
|
||||
return n, nil
|
||||
case "r":
|
||||
f, err := strconv.ParseFloat(strings.TrimSpace(c.V), 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%q - не число", c.V)
|
||||
}
|
||||
return f, nil
|
||||
case "x":
|
||||
b, err := hex.DecodeString(strings.ReplaceAll(c.V, " ", ""))
|
||||
if err != nil {
|
||||
return nil, errors.New("blob - нужны шестнадцатеричные байты")
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
return c.V, nil
|
||||
}
|
||||
|
||||
type dbArgs struct {
|
||||
Path string `json:"path"`
|
||||
Table string `json:"table,omitempty"`
|
||||
Offset int `json:"offset,omitempty"`
|
||||
Limit int `json:"limit,omitempty"`
|
||||
Key json.RawMessage `json:"key,omitempty"`
|
||||
Keys []json.RawMessage `json:"keys,omitempty"`
|
||||
Column string `json:"column,omitempty"`
|
||||
Value *dbCell `json:"value,omitempty"`
|
||||
Values map[string]dbCell `json:"values,omitempty"`
|
||||
SQL string `json:"sql,omitempty"`
|
||||
}
|
||||
|
||||
func dbOpen(path string) (*sql.DB, error) {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil, errors.New("это каталог")
|
||||
}
|
||||
u := url.URL{Scheme: "file", Path: path}
|
||||
db, err := sql.Open("sqlite", u.String()+"?mode=rw&_pragma=busy_timeout(5000)")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func qid(name string) string { return `"` + strings.ReplaceAll(name, `"`, `""`) + `"` }
|
||||
|
||||
// dbTable is a table's shape: its columns, and how a row is found again -
|
||||
// by rowid, by its primary key (a WITHOUT ROWID table), or not at all (a
|
||||
// view: shown, not changed).
|
||||
type dbTable struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Columns []dbColumn `json:"columns"`
|
||||
Key string `json:"key"` // "rowid", "pk" or ""
|
||||
// Alias is the INTEGER PRIMARY KEY column: another name for the rowid.
|
||||
Alias string `json:"alias,omitempty"`
|
||||
pk []string
|
||||
}
|
||||
|
||||
type dbColumn struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
NotNull bool `json:"notnull,omitempty"`
|
||||
PK bool `json:"pk,omitempty"`
|
||||
Default string `json:"default,omitempty"`
|
||||
}
|
||||
|
||||
func dbTableInfo(ctx context.Context, db *sql.DB, name string) (*dbTable, error) {
|
||||
t := &dbTable{Name: name}
|
||||
if err := db.QueryRowContext(ctx, `SELECT type FROM sqlite_master WHERE name = ? AND type IN ('table','view')`, name).Scan(&t.Type); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, fmt.Errorf("нет таблицы %s", name)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
rows, err := db.QueryContext(ctx, `SELECT name, type, "notnull", dflt_value, pk FROM pragma_table_info(?)`, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
type pkc struct {
|
||||
n int
|
||||
col string
|
||||
}
|
||||
var pks []pkc
|
||||
for rows.Next() {
|
||||
var c dbColumn
|
||||
var notnull, pk int
|
||||
var dflt sql.NullString
|
||||
if err := rows.Scan(&c.Name, &c.Type, ¬null, &dflt, &pk); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.NotNull, c.PK, c.Default = notnull == 1, pk > 0, dflt.String
|
||||
if pk > 0 {
|
||||
pks = append(pks, pkc{pk, c.Name})
|
||||
}
|
||||
t.Columns = append(t.Columns, c)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := 1; i <= len(pks); i++ {
|
||||
for _, p := range pks {
|
||||
if p.n == i {
|
||||
t.pk = append(t.pk, p.col)
|
||||
}
|
||||
}
|
||||
}
|
||||
if t.Type == "table" {
|
||||
if r, err := db.QueryContext(ctx, `SELECT rowid FROM `+qid(name)+` LIMIT 0`); err == nil {
|
||||
r.Close()
|
||||
t.Key = "rowid"
|
||||
if len(t.pk) == 1 {
|
||||
for _, c := range t.Columns {
|
||||
if c.Name == t.pk[0] && strings.EqualFold(strings.TrimSpace(c.Type), "INTEGER") {
|
||||
t.Alias = c.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if len(t.pk) > 0 {
|
||||
t.Key = "pk"
|
||||
}
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// where is the condition that finds the row by its key: a rowid, or the
|
||||
// primary key's cells in order.
|
||||
func (t *dbTable) where(key json.RawMessage) (string, []any, error) {
|
||||
switch t.Key {
|
||||
case "rowid":
|
||||
var id int64
|
||||
if err := json.Unmarshal(key, &id); err != nil {
|
||||
var s string
|
||||
if json.Unmarshal(key, &s) != nil {
|
||||
return "", nil, errors.New("плохой ключ строки")
|
||||
}
|
||||
if id, err = strconv.ParseInt(s, 10, 64); err != nil {
|
||||
return "", nil, errors.New("плохой ключ строки")
|
||||
}
|
||||
}
|
||||
return "rowid = ?", []any{id}, nil
|
||||
case "pk":
|
||||
var cells []dbCell
|
||||
if err := json.Unmarshal(key, &cells); err != nil || len(cells) != len(t.pk) {
|
||||
return "", nil, errors.New("плохой ключ строки")
|
||||
}
|
||||
parts, args := make([]string, len(t.pk)), make([]any, len(t.pk))
|
||||
for i, col := range t.pk {
|
||||
v, err := cells[i].value()
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
parts[i], args[i] = qid(col)+" IS ?", v
|
||||
}
|
||||
return strings.Join(parts, " AND "), args, nil
|
||||
}
|
||||
return "", nil, errors.New("строки этой таблицы нельзя менять: нет ни rowid, ни первичного ключа")
|
||||
}
|
||||
|
||||
func (t *dbTable) column(name string) bool {
|
||||
for _, c := range t.Columns {
|
||||
if c.Name == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// dbTask runs one database task.
|
||||
func dbTask(env proto.TaskEnvelope) (any, error) {
|
||||
var a dbArgs
|
||||
if err := json.Unmarshal(env.Args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db, err := dbOpen(a.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer db.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if env.Type == proto.TaskDBTables {
|
||||
rows, err := db.QueryContext(ctx, `SELECT name, type FROM sqlite_master WHERE type IN ('table','view') AND name NOT LIKE 'sqlite_%' ORDER BY type, name`)
|
||||
if err != nil {
|
||||
return nil, dbErr(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
type item struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
out := []item{}
|
||||
for rows.Next() {
|
||||
var it item
|
||||
if err := rows.Scan(&it.Name, &it.Type); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, it)
|
||||
}
|
||||
return map[string]any{"tables": out}, rows.Err()
|
||||
}
|
||||
if env.Type == proto.TaskDBSQL {
|
||||
return dbSQL(ctx, db, a.SQL)
|
||||
}
|
||||
|
||||
t, err := dbTableInfo(ctx, db, a.Table)
|
||||
if err != nil {
|
||||
return nil, dbErr(err)
|
||||
}
|
||||
switch env.Type {
|
||||
case proto.TaskDBRows:
|
||||
return dbRows(ctx, db, t, a.Offset, a.Limit)
|
||||
case proto.TaskDBSet:
|
||||
if !t.column(a.Column) || a.Value == nil {
|
||||
return nil, errors.New("нет такого столбца")
|
||||
}
|
||||
cond, args, err := t.where(a.Key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v, err := a.Value.value()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res, err := db.ExecContext(ctx, `UPDATE `+qid(t.Name)+` SET `+qid(a.Column)+` = ? WHERE `+cond, append([]any{v}, args...)...)
|
||||
if err != nil {
|
||||
return nil, dbErr(err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n != 1 {
|
||||
return nil, fmt.Errorf("изменено строк: %d - строку могли удалить или поменять", n)
|
||||
}
|
||||
// A key column changed: the row is found by its new key now.
|
||||
out := map[string]any{}
|
||||
switch {
|
||||
case t.Key == "rowid" && a.Column == t.Alias:
|
||||
args = []any{v}
|
||||
out["key"] = a.Value.V
|
||||
case t.Key == "pk":
|
||||
var cells []dbCell
|
||||
_ = json.Unmarshal(a.Key, &cells)
|
||||
for i, col := range t.pk {
|
||||
if col == a.Column {
|
||||
args[i], cells[i] = v, *a.Value
|
||||
out["key"] = cells
|
||||
}
|
||||
}
|
||||
}
|
||||
// What SQLite stored, after its type affinity.
|
||||
var got any
|
||||
_ = db.QueryRowContext(ctx, `SELECT `+qid(a.Column)+` FROM `+qid(t.Name)+` WHERE `+cond, args...).Scan(&got)
|
||||
out["value"] = cellOf(got)
|
||||
if k, ok := out["key"].([]dbCell); ok {
|
||||
for i, col := range t.pk {
|
||||
if col == a.Column {
|
||||
k[i] = cellOf(got)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
case proto.TaskDBAdd:
|
||||
if t.Type != "table" {
|
||||
return nil, errors.New("в представление строки не добавляются")
|
||||
}
|
||||
var cols, marks []string
|
||||
var vals []any
|
||||
for name, c := range a.Values {
|
||||
if !t.column(name) {
|
||||
return nil, fmt.Errorf("нет столбца %s", name)
|
||||
}
|
||||
v, err := c.value()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cols, marks, vals = append(cols, qid(name)), append(marks, "?"), append(vals, v)
|
||||
}
|
||||
q := `INSERT INTO ` + qid(t.Name) + ` DEFAULT VALUES`
|
||||
if len(cols) > 0 {
|
||||
q = `INSERT INTO ` + qid(t.Name) + ` (` + strings.Join(cols, ", ") + `) VALUES (` + strings.Join(marks, ", ") + `)`
|
||||
}
|
||||
res, err := db.ExecContext(ctx, q, vals...)
|
||||
if err != nil {
|
||||
return nil, dbErr(err)
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return map[string]any{"rowid": id}, nil
|
||||
case proto.TaskDBDel:
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, dbErr(err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
n := int64(0)
|
||||
for _, k := range a.Keys {
|
||||
cond, args, err := t.where(k)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res, err := tx.ExecContext(ctx, `DELETE FROM `+qid(t.Name)+` WHERE `+cond, args...)
|
||||
if err != nil {
|
||||
return nil, dbErr(err)
|
||||
}
|
||||
c, _ := res.RowsAffected()
|
||||
n += c
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, dbErr(err)
|
||||
}
|
||||
return map[string]any{"deleted": n}, nil
|
||||
}
|
||||
return nil, errors.New("неизвестная операция")
|
||||
}
|
||||
|
||||
// dbRows is a page of a table's rows, with each row's key.
|
||||
func dbRows(ctx context.Context, db *sql.DB, t *dbTable, offset, limit int) (any, error) {
|
||||
if limit <= 0 || limit > 1000 {
|
||||
limit = 200
|
||||
}
|
||||
var total int64
|
||||
_ = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM `+qid(t.Name)).Scan(&total)
|
||||
sel := make([]string, 0, len(t.Columns)+1)
|
||||
if t.Key == "rowid" {
|
||||
sel = append(sel, "rowid")
|
||||
}
|
||||
for _, c := range t.Columns {
|
||||
sel = append(sel, qid(c.Name))
|
||||
}
|
||||
order := ""
|
||||
switch t.Key {
|
||||
case "rowid":
|
||||
order = " ORDER BY rowid"
|
||||
case "pk":
|
||||
parts := make([]string, len(t.pk))
|
||||
for i, c := range t.pk {
|
||||
parts[i] = qid(c)
|
||||
}
|
||||
order = " ORDER BY " + strings.Join(parts, ", ")
|
||||
}
|
||||
rows, err := db.QueryContext(ctx, `SELECT `+strings.Join(sel, ", ")+` FROM `+qid(t.Name)+order+` LIMIT ? OFFSET ?`, limit, offset)
|
||||
if err != nil {
|
||||
return nil, dbErr(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
type row struct {
|
||||
K any `json:"k,omitempty"`
|
||||
V []dbCell `json:"v"`
|
||||
}
|
||||
out := []row{}
|
||||
n := len(sel)
|
||||
for rows.Next() {
|
||||
vals := make([]any, n)
|
||||
ptrs := make([]any, n)
|
||||
for i := range vals {
|
||||
ptrs[i] = &vals[i]
|
||||
}
|
||||
if err := rows.Scan(ptrs...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r := row{}
|
||||
if t.Key == "rowid" {
|
||||
r.K = cellOf(vals[0]).V
|
||||
vals = vals[1:]
|
||||
}
|
||||
for _, v := range vals {
|
||||
r.V = append(r.V, cellOf(v))
|
||||
}
|
||||
if t.Key == "pk" {
|
||||
key := make([]dbCell, len(t.pk))
|
||||
for i, pk := range t.pk {
|
||||
for j, c := range t.Columns {
|
||||
if c.Name == pk {
|
||||
key[i] = r.V[j]
|
||||
}
|
||||
}
|
||||
}
|
||||
r.K = key
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return map[string]any{"table": t, "total": total, "offset": offset, "rows": out}, rows.Err()
|
||||
}
|
||||
|
||||
// dbSQL runs what was typed: a query gives up to 500 rows, anything else
|
||||
// how many rows it changed.
|
||||
func dbSQL(ctx context.Context, db *sql.DB, q string) (any, error) {
|
||||
q = strings.TrimSpace(q)
|
||||
if q == "" {
|
||||
return nil, errors.New("пустой запрос")
|
||||
}
|
||||
head := strings.ToLower(strings.Fields(q)[0])
|
||||
if head == "select" || head == "pragma" || head == "with" || head == "explain" || head == "values" {
|
||||
rows, err := db.QueryContext(ctx, q)
|
||||
if err != nil {
|
||||
return nil, dbErr(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
cols, _ := rows.Columns()
|
||||
out := [][]dbCell{}
|
||||
more := false
|
||||
for rows.Next() {
|
||||
if len(out) == 500 {
|
||||
more = true
|
||||
break
|
||||
}
|
||||
vals := make([]any, len(cols))
|
||||
ptrs := make([]any, len(cols))
|
||||
for i := range vals {
|
||||
ptrs[i] = &vals[i]
|
||||
}
|
||||
if err := rows.Scan(ptrs...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r := make([]dbCell, len(cols))
|
||||
for i, v := range vals {
|
||||
r[i] = cellOf(v)
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return map[string]any{"columns": cols, "rows": out, "more": more}, dbErr(rows.Err())
|
||||
}
|
||||
res, err := db.ExecContext(ctx, q)
|
||||
if err != nil {
|
||||
return nil, dbErr(err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return map[string]any{"changed": n}, nil
|
||||
}
|
||||
|
||||
// dbErr says the common SQLite troubles in words.
|
||||
func dbErr(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
m := err.Error()
|
||||
switch {
|
||||
case strings.Contains(m, "file is not a database"):
|
||||
return errors.New("это не база SQLite")
|
||||
case strings.Contains(m, "database is locked"), strings.Contains(m, "SQLITE_BUSY"):
|
||||
return errors.New("база занята другой программой - попробуйте ещё раз")
|
||||
case strings.Contains(m, "readonly"):
|
||||
return errors.New("база только для чтения (нет прав на файл или его папку)")
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
|
||||
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||
)
|
||||
|
||||
// An SSH terminal: the agent connects to another host by SSH and bridges the
|
||||
// remote shell to the session, as it does a local one. The panel is told how
|
||||
// the connection goes (connecting, signing in, in) to show it.
|
||||
|
||||
// sshKnownHosts holds the host keys seen, next to the agent's state: a host
|
||||
// is trusted the first time, and a changed key is refused.
|
||||
func (a *Agent) sshKnownHosts() string {
|
||||
return filepath.Join(filepath.Dir(a.cfg.StatePath), "ssh_known_hosts")
|
||||
}
|
||||
|
||||
var knownHostsMu sync.Mutex
|
||||
|
||||
// hostKeyCheck trusts a new host and remembers its key; a host whose key
|
||||
// has changed since is refused, as ssh would.
|
||||
func (a *Agent) hostKeyCheck(seen *string) ssh.HostKeyCallback {
|
||||
return func(hostname string, _ net.Addr, key ssh.PublicKey) error {
|
||||
knownHostsMu.Lock()
|
||||
defer knownHostsMu.Unlock()
|
||||
fp := ssh.FingerprintSHA256(key)
|
||||
*seen = fp
|
||||
path := a.sshKnownHosts()
|
||||
line := hostname + " " + key.Type() + " "
|
||||
if f, err := os.Open(path); err == nil {
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
fields := strings.Fields(sc.Text())
|
||||
if len(fields) != 3 || fields[0] != hostname {
|
||||
continue
|
||||
}
|
||||
if fields[1] == key.Type() && fields[2] == fp {
|
||||
f.Close()
|
||||
return nil
|
||||
}
|
||||
if fields[1] == key.Type() {
|
||||
f.Close()
|
||||
return fmt.Errorf("ключ хоста %s изменился: был %s, теперь %s. Если так и должно быть, удалите строку с этим хостом из %s на агенте",
|
||||
hostname, fields[2], fp, path)
|
||||
}
|
||||
}
|
||||
f.Close()
|
||||
}
|
||||
_ = os.MkdirAll(filepath.Dir(path), 0o700)
|
||||
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
|
||||
if err == nil {
|
||||
_, _ = f.WriteString(line + fp + "\n")
|
||||
f.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// sshAuth is how to sign in: the key given, the password given (also
|
||||
// answered to keyboard-interactive prompts), or the agent user's own keys.
|
||||
func sshAuth(t proto.SSHTarget) ([]ssh.AuthMethod, error) {
|
||||
var methods []ssh.AuthMethod
|
||||
if t.Key != "" {
|
||||
var signer ssh.Signer
|
||||
var err error
|
||||
if t.Passphrase != "" {
|
||||
signer, err = ssh.ParsePrivateKeyWithPassphrase([]byte(t.Key), []byte(t.Passphrase))
|
||||
} else {
|
||||
signer, err = ssh.ParsePrivateKey([]byte(t.Key))
|
||||
}
|
||||
var missing *ssh.PassphraseMissingError
|
||||
if errors.As(err, &missing) {
|
||||
return nil, errors.New("ключ защищён фразой - введите её")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ключ не читается: %v", err)
|
||||
}
|
||||
methods = append(methods, ssh.PublicKeys(signer))
|
||||
}
|
||||
if t.Password != "" {
|
||||
pw := t.Password
|
||||
methods = append(methods, ssh.Password(pw),
|
||||
ssh.KeyboardInteractive(func(_, _ string, qs []string, _ []bool) ([]string, error) {
|
||||
ans := make([]string, len(qs))
|
||||
for i := range ans {
|
||||
ans[i] = pw
|
||||
}
|
||||
return ans, nil
|
||||
}))
|
||||
}
|
||||
if len(methods) == 0 {
|
||||
var signers []ssh.Signer
|
||||
for _, n := range []string{"id_ed25519", "id_ecdsa", "id_rsa"} {
|
||||
b, err := os.ReadFile(filepath.Join(homeDir(""), ".ssh", n))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if s, err := ssh.ParsePrivateKey(b); err == nil {
|
||||
signers = append(signers, s)
|
||||
}
|
||||
}
|
||||
if len(signers) == 0 {
|
||||
return nil, errors.New("не задан ни пароль, ни ключ, и в ~/.ssh агента нет ключей без фразы")
|
||||
}
|
||||
methods = append(methods, ssh.PublicKeys(signers...))
|
||||
}
|
||||
return methods, nil
|
||||
}
|
||||
|
||||
// sshShell is the remote shell as a stream: its output (stdout and stderr,
|
||||
// one on a PTY) to read, keystrokes to write.
|
||||
type sshShell struct {
|
||||
client *ssh.Client
|
||||
sess *ssh.Session
|
||||
stdin io.WriteCloser
|
||||
out *io.PipeReader
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (s *sshShell) Read(b []byte) (int, error) { return s.out.Read(b) }
|
||||
func (s *sshShell) Write(b []byte) (int, error) { return s.stdin.Write(b) }
|
||||
func (s *sshShell) Resize(cols, rows uint16) {
|
||||
if cols > 0 && rows > 0 {
|
||||
_ = s.sess.WindowChange(int(rows), int(cols))
|
||||
}
|
||||
}
|
||||
func (s *sshShell) Close() error {
|
||||
s.once.Do(func() {
|
||||
_ = s.sess.Close()
|
||||
_ = s.client.Close()
|
||||
_ = s.out.Close()
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// sshConnect connects and signs in to t, saying how it goes by status (the
|
||||
// SSHStatus steps). It returns the client and the host key's fingerprint.
|
||||
func (a *Agent) sshConnect(ctx context.Context, t proto.SSHTarget, status func(st, note string)) (*ssh.Client, string, error) {
|
||||
if t.Port == 0 {
|
||||
t.Port = 22
|
||||
}
|
||||
addr := net.JoinHostPort(t.Host, strconv.Itoa(t.Port))
|
||||
auth, err := sshAuth(t)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
status(proto.SSHDial, "подключение к "+addr)
|
||||
d := net.Dialer{Timeout: 15 * time.Second, KeepAlive: 30 * time.Second}
|
||||
conn, err := d.DialContext(ctx, "tcp", addr)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("не подключиться к %s: %v", addr, err)
|
||||
}
|
||||
status(proto.SSHAuth, "вход как "+t.User)
|
||||
var fp string
|
||||
_ = conn.SetDeadline(time.Now().Add(30 * time.Second))
|
||||
c, chans, reqs, err := ssh.NewClientConn(conn, addr, &ssh.ClientConfig{
|
||||
User: t.User,
|
||||
Auth: auth,
|
||||
HostKeyCallback: a.hostKeyCheck(&fp),
|
||||
Timeout: 15 * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
msg := strings.TrimPrefix(err.Error(), "ssh: handshake failed: ")
|
||||
if strings.Contains(msg, "unable to authenticate") {
|
||||
msg = "сервер не принял вход как " + t.User + " - проверьте пароль или ключ"
|
||||
}
|
||||
return nil, "", errors.New(msg)
|
||||
}
|
||||
_ = conn.SetDeadline(time.Time{})
|
||||
return ssh.NewClient(c, chans, reqs), fp, nil
|
||||
}
|
||||
|
||||
// runSSH connects to the target and bridges its shell to the session.
|
||||
func (a *Agent) runSSH(parent context.Context, sid string, t proto.SSHTarget) {
|
||||
status := func(st, note string) {
|
||||
a.postOut(context.Background(), sid, proto.OutMessage{Status: st, Note: note})
|
||||
}
|
||||
fail := func(err error) {
|
||||
log.Printf("ssh %s: %v", sid, err)
|
||||
status(proto.SSHErr, err.Error())
|
||||
a.postOut(context.Background(), sid, proto.OutMessage{Data: b64.EncodeToString([]byte("\x1b[31m" + err.Error() + "\x1b[0m\r\n"))})
|
||||
a.postOut(context.Background(), sid, proto.OutMessage{Exit: true})
|
||||
}
|
||||
client, fp, err := a.sshConnect(parent, t, status)
|
||||
if err != nil {
|
||||
fail(err)
|
||||
return
|
||||
}
|
||||
port := t.Port
|
||||
if port == 0 {
|
||||
port = 22
|
||||
}
|
||||
addr := net.JoinHostPort(t.Host, strconv.Itoa(port))
|
||||
sess, err := client.NewSession()
|
||||
if err != nil {
|
||||
client.Close()
|
||||
fail(fmt.Errorf("сессия не открылась: %v", err))
|
||||
return
|
||||
}
|
||||
modes := ssh.TerminalModes{ssh.ECHO: 1, ssh.TTY_OP_ISPEED: 115200, ssh.TTY_OP_OSPEED: 115200}
|
||||
if err := sess.RequestPty("xterm-256color", 24, 80, modes); err != nil {
|
||||
sess.Close()
|
||||
client.Close()
|
||||
fail(fmt.Errorf("терминал не выделен: %v", err))
|
||||
return
|
||||
}
|
||||
stdin, _ := sess.StdinPipe()
|
||||
pr, pw := io.Pipe()
|
||||
sess.Stdout = pw
|
||||
sess.Stderr = pw
|
||||
if err := sess.Shell(); err != nil {
|
||||
sess.Close()
|
||||
client.Close()
|
||||
fail(fmt.Errorf("оболочка не запустилась: %v", err))
|
||||
return
|
||||
}
|
||||
sh := &sshShell{client: client, sess: sess, stdin: stdin, out: pr}
|
||||
go func() { _ = sess.Wait(); pw.Close() }()
|
||||
// Keep-alives, so a dead host is noticed and the session ends.
|
||||
go func() {
|
||||
tk := time.NewTicker(30 * time.Second)
|
||||
defer tk.Stop()
|
||||
for range tk.C {
|
||||
if _, _, err := client.SendRequest("keepalive@openssh.com", true, nil); err != nil {
|
||||
sh.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
log.Printf("ssh %s: %s@%s up (host key %s)", sid, t.User, addr, fp)
|
||||
status(proto.SSHOK, t.User+"@"+addr)
|
||||
a.bridgeStream(parent, sid, sh, sh.Resize)
|
||||
log.Printf("ssh %s: ended", sid)
|
||||
}
|
||||
@@ -0,0 +1,794 @@
|
||||
package agent
|
||||
|
||||
// Files on an SSH host, for the panel's file panes: the agent keeps one SSH
|
||||
// connection per pane and runs plain commands there - stat, cat, mv, rm - as
|
||||
// the tar transfer does. No SFTP is needed on the host. A connection that
|
||||
// dropped is opened again once with what it was opened with; one left alone
|
||||
// for sshfsIdle is closed.
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
|
||||
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||
)
|
||||
|
||||
const sshfsIdle = 10 * time.Minute
|
||||
|
||||
type sshfsConn struct {
|
||||
mu sync.Mutex
|
||||
client *ssh.Client
|
||||
target proto.SSHTarget // kept in the agent's memory to reconnect once after a drop
|
||||
statFmt string // "gnu", "bsd" or "ls"
|
||||
home string
|
||||
last time.Time
|
||||
busy int // transfers under way: the sweep leaves the connection be
|
||||
}
|
||||
|
||||
// hold marks the connection busy for a long transfer; the returned func ends
|
||||
// it and starts the idle clock again.
|
||||
func (c *sshfsConn) hold() func() {
|
||||
c.mu.Lock()
|
||||
c.busy++
|
||||
c.mu.Unlock()
|
||||
return func() {
|
||||
c.mu.Lock()
|
||||
c.busy--
|
||||
c.last = time.Now()
|
||||
c.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// idle: nothing under way and unused for sshfsIdle by now.
|
||||
func (c *sshfsConn) idle(now time.Time) bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.busy == 0 && now.Sub(c.last) > sshfsIdle
|
||||
}
|
||||
|
||||
var (
|
||||
sshfsConns sync.Map // id -> *sshfsConn
|
||||
sshfsJanitor sync.Once
|
||||
)
|
||||
|
||||
// statCommand lists dir's entries as "type|size|mtime|mode|name" lines.
|
||||
func statCommand(flavour, dir string) string {
|
||||
d := shq(strings.TrimSuffix(dir, "/") + "/")
|
||||
// Each line starts with LD for a link to a folder, so it opens like one.
|
||||
loop := "cd " + d + " && for f in .[!.]* ..?* *; do [ -e \"$f\" ] || [ -L \"$f\" ] || continue; " +
|
||||
"t=; [ -L \"$f\" ] && [ -d \"$f\" ] && t=LD; printf '%s|' \"$t\"; "
|
||||
switch flavour {
|
||||
case "bsd":
|
||||
return loop + "stat -f '%HT|%z|%m|%Sp|%N' -- \"$f\"; done"
|
||||
case "ls":
|
||||
return "cd " + d + " && LC_ALL=C ls -la"
|
||||
}
|
||||
return loop + "stat -c '%F|%s|%Y|%A|%n' -- \"$f\"; done"
|
||||
}
|
||||
|
||||
// parseStatLine reads one line of statCommand's output.
|
||||
func parseStatLine(line string) (proto.FSEntry, bool) {
|
||||
// The LD mark of statCommand: empty, or LD for a link to a folder.
|
||||
mark := ""
|
||||
if i := strings.IndexByte(line, '|'); i >= 0 && (line[:i] == "" || line[:i] == "LD") {
|
||||
mark, line = line[:i], line[i+1:]
|
||||
}
|
||||
p := strings.SplitN(line, "|", 5)
|
||||
if len(p) != 5 {
|
||||
return proto.FSEntry{}, false
|
||||
}
|
||||
size, err1 := strconv.ParseInt(p[1], 10, 64)
|
||||
mt, err2 := strconv.ParseInt(p[2], 10, 64)
|
||||
if err1 != nil || err2 != nil {
|
||||
return proto.FSEntry{}, false
|
||||
}
|
||||
t := strings.ToLower(p[0])
|
||||
return proto.FSEntry{Name: p[4], IsDir: strings.Contains(t, "directory") || mark == "LD", Size: size, MTime: mt, Mode: p[3],
|
||||
Link: strings.Contains(t, "link")}, true
|
||||
}
|
||||
|
||||
// parseLsLine reads a line of "ls -la" for a host with no stat: no times.
|
||||
func parseLsLine(line string) (proto.FSEntry, bool) {
|
||||
f := strings.Fields(line)
|
||||
if len(f) < 9 || len(f[0]) < 10 {
|
||||
return proto.FSEntry{}, false
|
||||
}
|
||||
size, err := strconv.ParseInt(f[4], 10, 64)
|
||||
if err != nil {
|
||||
return proto.FSEntry{}, false
|
||||
}
|
||||
// The name is what follows the eighth field, spaces and all.
|
||||
rest := line
|
||||
for i := 0; i < 8; i++ {
|
||||
rest = strings.TrimLeft(rest, " ")
|
||||
rest = rest[strings.IndexByte(rest, ' ')+1:]
|
||||
}
|
||||
name := strings.TrimLeft(rest, " ")
|
||||
if f[0][0] == 'l' {
|
||||
if i := strings.Index(name, " -> "); i >= 0 {
|
||||
name = name[:i]
|
||||
}
|
||||
}
|
||||
if name == "." || name == ".." {
|
||||
return proto.FSEntry{}, false
|
||||
}
|
||||
return proto.FSEntry{Name: name, IsDir: f[0][0] == 'd', Size: size, Mode: f[0]}, true
|
||||
}
|
||||
|
||||
func randHex(n int) string {
|
||||
b := make([]byte, n)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// sshfsOpen connects for a file pane and tells the host's home.
|
||||
func (a *Agent) sshfsOpen(ctx context.Context, t proto.SSHTarget) (any, error) {
|
||||
client, _, err := a.sshConnect(ctx, t, func(string, string) {})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c := &sshfsConn{client: client, target: t, last: time.Now()}
|
||||
out, _ := sshRun(client, "stat -c %F / >/dev/null 2>&1 && echo GNU; stat -f %HT / >/dev/null 2>&1 && echo BSD; "+
|
||||
"command -v zip >/dev/null 2>&1 && echo ZIP; command -v unzip >/dev/null 2>&1 && echo UNZIP; "+
|
||||
"command -v python3 >/dev/null 2>&1 && echo PY; echo \"$HOME\"")
|
||||
lines := strings.Split(strings.TrimSpace(out), "\n")
|
||||
switch {
|
||||
case strings.Contains(out, "GNU"):
|
||||
c.statFmt = "gnu"
|
||||
case strings.Contains(out, "BSD"):
|
||||
c.statFmt = "bsd"
|
||||
default:
|
||||
c.statFmt = "ls"
|
||||
}
|
||||
c.home = strings.TrimSpace(lines[len(lines)-1])
|
||||
if !strings.HasPrefix(c.home, "/") {
|
||||
c.home = "/"
|
||||
}
|
||||
id := randHex(12)
|
||||
sshfsConns.Store(id, c)
|
||||
sshfsJanitor.Do(func() { go sshfsSweep() })
|
||||
// zip and unzip, where the host has them, for the pane's buttons.
|
||||
return map[string]any{"id": id, "home": c.home,
|
||||
"zip": hasLine(lines, "ZIP") || hasLine(lines, "PY"), "unzip": hasLine(lines, "UNZIP") || hasLine(lines, "PY")}, nil
|
||||
}
|
||||
|
||||
func hasLine(lines []string, w string) bool {
|
||||
for _, l := range lines {
|
||||
if strings.TrimSpace(l) == w {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// sshfsSweep closes connections nobody used for sshfsIdle.
|
||||
func sshfsSweep() {
|
||||
for range time.Tick(time.Minute) {
|
||||
sshfsConns.Range(func(k, v any) bool {
|
||||
if v.(*sshfsConn).idle(time.Now()) {
|
||||
sshfsClose(k.(string))
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func sshfsClose(id string) {
|
||||
if v, ok := sshfsConns.LoadAndDelete(id); ok {
|
||||
_ = v.(*sshfsConn).client.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func sshfsGet(id string) (*sshfsConn, error) {
|
||||
v, ok := sshfsConns.Load(id)
|
||||
if !ok {
|
||||
return nil, errors.New("подключение закрыто - откройте файлы по SSH снова")
|
||||
}
|
||||
c := v.(*sshfsConn)
|
||||
c.mu.Lock()
|
||||
c.last = time.Now()
|
||||
c.mu.Unlock()
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// session opens an SSH session on the connection; a connection that dropped
|
||||
// is opened again once.
|
||||
func (a *Agent) sshfsSession(c *sshfsConn) (*ssh.Session, error) {
|
||||
c.mu.Lock()
|
||||
client := c.client
|
||||
c.mu.Unlock()
|
||||
s, err := client.NewSession()
|
||||
if err == nil {
|
||||
return s, nil
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 40*time.Second)
|
||||
defer cancel()
|
||||
nc, _, cerr := a.sshConnect(ctx, c.target, func(string, string) {})
|
||||
if cerr != nil {
|
||||
return nil, fmt.Errorf("подключение к хосту потеряно: %v", cerr)
|
||||
}
|
||||
c.mu.Lock()
|
||||
_ = c.client.Close()
|
||||
c.client = nc
|
||||
c.mu.Unlock()
|
||||
return nc.NewSession()
|
||||
}
|
||||
|
||||
// run runs cmd on the host with stdin, returning its output; a failure
|
||||
// carries what the command said on stderr.
|
||||
func (a *Agent) sshfsRun(c *sshfsConn, cmd string, stdin io.Reader) ([]byte, error) {
|
||||
s, err := a.sshfsSession(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer s.Close()
|
||||
var out, errb bytes.Buffer
|
||||
s.Stdout = &out
|
||||
s.Stderr = &limitBuf{b: &errb, max: 4096}
|
||||
if stdin != nil {
|
||||
s.Stdin = stdin
|
||||
}
|
||||
if err := s.Run(posix(cmd)); err != nil {
|
||||
msg := strings.TrimSpace(errb.String())
|
||||
if msg == "" {
|
||||
msg = err.Error()
|
||||
}
|
||||
if len(msg) > 300 {
|
||||
msg = msg[:300]
|
||||
}
|
||||
return out.Bytes(), errors.New(msg)
|
||||
}
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
// sshfsTask runs one sshfs.* task.
|
||||
func (a *Agent) sshfsTask(ctx context.Context, env proto.TaskEnvelope) (any, error) {
|
||||
var args proto.SSHFSArgs
|
||||
if err := json.Unmarshal(env.Args, &args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch env.Type {
|
||||
case proto.TaskSSHFSOpen:
|
||||
if args.SSH == nil {
|
||||
return nil, errors.New("не указан хост")
|
||||
}
|
||||
return a.sshfsOpen(ctx, *args.SSH)
|
||||
case proto.TaskSSHFSClose:
|
||||
sshfsClose(args.Conn)
|
||||
return nil, nil
|
||||
}
|
||||
c, err := sshfsGet(args.Conn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a.sshfsOp(c, args.Op, args.Args)
|
||||
}
|
||||
|
||||
func (a *Agent) sshfsOp(c *sshfsConn, op string, raw json.RawMessage) (any, error) {
|
||||
switch op {
|
||||
case "list":
|
||||
var p proto.FSPathArgs
|
||||
_ = json.Unmarshal(raw, &p)
|
||||
dir := p.Path
|
||||
if dir == "" || strings.HasPrefix(dir, "~") {
|
||||
dir = c.home
|
||||
}
|
||||
out, err := a.sshfsRun(c, statCommand(c.statFmt, dir), nil)
|
||||
if err != nil && len(out) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
entries := []proto.FSEntry{}
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
var e proto.FSEntry
|
||||
var ok bool
|
||||
if c.statFmt == "ls" {
|
||||
e, ok = parseLsLine(line)
|
||||
} else {
|
||||
e, ok = parseStatLine(line)
|
||||
}
|
||||
if ok {
|
||||
entries = append(entries, e)
|
||||
}
|
||||
}
|
||||
return proto.FSListResult{Path: path.Clean(dir), Entries: entries}, nil
|
||||
case "read":
|
||||
var p proto.FSPathArgs
|
||||
_ = json.Unmarshal(raw, &p)
|
||||
size, _ := a.sshfsSize(c, p.Path)
|
||||
out, err := a.sshfsRun(c, "head -c "+strconv.Itoa(proto.FSReadCap+1)+" -- "+shq(p.Path), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
truncated := len(out) > proto.FSReadCap
|
||||
if truncated {
|
||||
out = out[:proto.FSReadCap]
|
||||
}
|
||||
return proto.FSReadResult{Path: p.Path, Size: size, Content: base64.StdEncoding.EncodeToString(out),
|
||||
Truncated: truncated, Binary: !utf8.Valid(out) || bytes.IndexByte(out, 0) >= 0}, nil
|
||||
case "write":
|
||||
var p proto.FSWriteArgs
|
||||
_ = json.Unmarshal(raw, &p)
|
||||
data, err := base64.StdEncoding.DecodeString(p.Data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = a.sshfsRun(c, writeCommand(p.Path), bytes.NewReader(data))
|
||||
return map[string]string{"path": p.Path}, err
|
||||
case "mkdir":
|
||||
var p proto.FSPathArgs
|
||||
_ = json.Unmarshal(raw, &p)
|
||||
_, err := a.sshfsRun(c, "mkdir -p -- "+shq(p.Path), nil)
|
||||
return map[string]string{"path": p.Path}, err
|
||||
case "delete", "deltree":
|
||||
var p proto.FSPathArgs
|
||||
_ = json.Unmarshal(raw, &p)
|
||||
if p.Path == "" || path.Clean(p.Path) == "/" {
|
||||
return nil, errors.New("отказ: удаление корня запрещено")
|
||||
}
|
||||
flag := "-f"
|
||||
if op == "deltree" {
|
||||
flag = "-rf"
|
||||
}
|
||||
_, err := a.sshfsRun(c, "rm "+flag+" -- "+shq(p.Path), nil)
|
||||
return map[string]string{"path": p.Path}, err
|
||||
case "move":
|
||||
var p proto.FSMoveArgs
|
||||
_ = json.Unmarshal(raw, &p)
|
||||
_, err := a.sshfsRun(c, "mv -- "+shq(p.Src)+" "+shq(p.Dst), nil)
|
||||
return map[string]string{"src": p.Src, "dst": p.Dst}, err
|
||||
case "copy":
|
||||
var p proto.FSCopyArgs
|
||||
_ = json.Unmarshal(raw, &p)
|
||||
return nil, a.sshfsCopy(c, p)
|
||||
case "zip":
|
||||
var p proto.FSZipArgs
|
||||
_ = json.Unmarshal(raw, &p)
|
||||
cmd, err := zipCommand(p.Paths, p.Dest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = a.sshfsRun(c, cmd, nil)
|
||||
return map[string]string{"dest": p.Dest}, err
|
||||
case "unzip":
|
||||
var p proto.FSUnzipArgs
|
||||
_ = json.Unmarshal(raw, &p)
|
||||
_, err := a.sshfsRun(c, unzipCommand(p.Path, p.Dest), nil)
|
||||
return map[string]string{"dest": p.Dest}, err
|
||||
case "stat":
|
||||
var p proto.FSPathArgs
|
||||
_ = json.Unmarshal(raw, &p)
|
||||
return a.sshfsStat(c, p.Path)
|
||||
case "chmod":
|
||||
var p proto.FSChmodArgs
|
||||
_ = json.Unmarshal(raw, &p)
|
||||
rec := ""
|
||||
if p.Recursive {
|
||||
rec = "-R "
|
||||
}
|
||||
var cmds []string
|
||||
if p.Mode != nil {
|
||||
cmds = append(cmds, "chmod "+rec+fmt.Sprintf("%o", *p.Mode)+" -- "+shq(p.Path))
|
||||
}
|
||||
if p.Owner != "" || p.Group != "" {
|
||||
who := p.Owner
|
||||
if p.Group != "" {
|
||||
who += ":" + p.Group
|
||||
}
|
||||
cmds = append(cmds, "chown "+rec+shq(who)+" -- "+shq(p.Path))
|
||||
}
|
||||
if len(cmds) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
_, err := a.sshfsRun(c, strings.Join(cmds, " && "), nil)
|
||||
return map[string]string{"path": p.Path}, err
|
||||
}
|
||||
return nil, errors.New("неизвестная операция")
|
||||
}
|
||||
|
||||
// zipCommand packs paths - items of one folder - into the archive dest, from
|
||||
// that folder, under their own names. Paths are absolute and names start
|
||||
// with "./", so none reads as an option.
|
||||
func zipCommand(paths []string, dest string) (string, error) {
|
||||
if len(paths) == 0 {
|
||||
return "", errors.New("не выбраны файлы")
|
||||
}
|
||||
dir := path.Dir(path.Clean(paths[0]))
|
||||
names := make([]string, len(paths))
|
||||
for i, p := range paths {
|
||||
p = path.Clean(p)
|
||||
if path.Dir(p) != dir {
|
||||
return "", errors.New("в архив - только из одной папки")
|
||||
}
|
||||
names[i] = shq("./" + path.Base(p))
|
||||
}
|
||||
// zip where the host has it, else Python's zipfile, which most have.
|
||||
items := strings.Join(names, " ")
|
||||
return "cd -- " + shq(dir) + " && if command -v zip >/dev/null 2>&1; then zip -r -q -y " + shq(path.Clean(dest)) + " " + items + "; " +
|
||||
"else python3 -m zipfile -c " + shq(path.Clean(dest)) + " " + items + "; fi", nil
|
||||
}
|
||||
|
||||
// unzipCommand unpacks the archive p into dest (its own folder if empty),
|
||||
// replacing what is there, as on a machine.
|
||||
func unzipCommand(p, dest string) string {
|
||||
if dest == "" {
|
||||
dest = path.Dir(path.Clean(p))
|
||||
}
|
||||
return "if command -v unzip >/dev/null 2>&1; then unzip -o -q " + shq(path.Clean(p)) + " -d " + shq(path.Clean(dest)) + "; " +
|
||||
"else python3 -m zipfile -e " + shq(path.Clean(p)) + " " + shq(path.Clean(dest)) + "; fi"
|
||||
}
|
||||
|
||||
// writeCommand writes stdin to p through a file beside it, so a broken
|
||||
// transfer leaves the old file as it was.
|
||||
func writeCommand(p string) string {
|
||||
tmp := path.Join(path.Dir(p), "."+path.Base(p)+".lpra-"+randHex(4))
|
||||
return "cat > " + shq(tmp) + " && mv -f -- " + shq(tmp) + " " + shq(p) + " || { rm -f -- " + shq(tmp) + "; exit 1; }"
|
||||
}
|
||||
|
||||
// sizeCommand tells the size of p - of its target, if a link: what cat
|
||||
// reads.
|
||||
func sizeCommand(statFmt, p string) string {
|
||||
switch statFmt {
|
||||
case "bsd":
|
||||
return "stat -L -f %z -- " + shq(p)
|
||||
case "ls":
|
||||
return "wc -c < " + shq(p)
|
||||
}
|
||||
return "stat -L -c %s -- " + shq(p)
|
||||
}
|
||||
|
||||
func (a *Agent) sshfsSize(c *sshfsConn, p string) (int64, error) {
|
||||
out, err := a.sshfsRun(c, sizeCommand(c.statFmt, p), nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return strconv.ParseInt(strings.TrimSpace(string(out)), 10, 64)
|
||||
}
|
||||
|
||||
func (a *Agent) sshfsStat(c *sshfsConn, p string) (any, error) {
|
||||
cmd := "stat -c '%F|%s|%Y|%a|%u|%g|%U|%G' -- " + shq(p)
|
||||
if c.statFmt == "bsd" {
|
||||
cmd = "stat -f '%HT|%z|%m|%Lp|%u|%g|%Su|%Sg' -- " + shq(p)
|
||||
}
|
||||
out, err := a.sshfsRun(c, cmd, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f := strings.Split(strings.TrimSpace(string(out)), "|")
|
||||
if len(f) != 8 {
|
||||
return nil, errors.New("не удалось прочитать свойства")
|
||||
}
|
||||
size, _ := strconv.ParseInt(f[1], 10, 64)
|
||||
mt, _ := strconv.ParseInt(f[2], 10, 64)
|
||||
mode, _ := strconv.ParseUint(f[3], 8, 32)
|
||||
uid, _ := strconv.Atoi(f[4])
|
||||
gid, _ := strconv.Atoi(f[5])
|
||||
return proto.FSStatResult{Path: p, IsDir: strings.Contains(strings.ToLower(f[0]), "directory"), Size: size, MTime: mt,
|
||||
Mode: uint32(mode), UID: uid, GID: gid, Owner: f[6], Group: f[7], Users: []string{}, Groups: []string{}}, nil
|
||||
}
|
||||
|
||||
// sshfsCopy copies or moves on the host, by the conflict rule for what is
|
||||
// there already.
|
||||
func (a *Agent) sshfsCopy(c *sshfsConn, p proto.FSCopyArgs) error {
|
||||
src, dst := shq(p.Src), shq(p.Dst)
|
||||
exists, _ := a.sshfsRun(c, "test -e "+dst+" -o -L "+dst+" && echo EXISTS", nil)
|
||||
there := strings.Contains(string(exists), "EXISTS")
|
||||
if there {
|
||||
switch p.Conflict {
|
||||
case "replace":
|
||||
if _, err := a.sshfsRun(c, "rm -rf -- "+dst, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
case "skip":
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("%s уже существует", p.Dst)
|
||||
}
|
||||
}
|
||||
cmd := "cp -a -- " + src + " " + dst
|
||||
if p.Move {
|
||||
cmd = "mv -- " + src + " " + dst
|
||||
}
|
||||
_, err := a.sshfsRun(c, cmd, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// doSSHFSSend sends a file of the host to the browser in pieces, as
|
||||
// doFSSend does with a local one: cat's output, piece after piece.
|
||||
func (a *Agent) doSSHFSSend(ctx context.Context, env proto.TaskEnvelope) proto.TaskResult {
|
||||
var args proto.FSXferArgs
|
||||
if err := json.Unmarshal(env.Args, &args); err != nil || args.Chunk <= 0 {
|
||||
return proto.TaskResult{Error: "плохой запрос"}
|
||||
}
|
||||
c, err := sshfsGet(args.Conn)
|
||||
if err != nil {
|
||||
return proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
defer c.hold()()
|
||||
size, err := a.sshfsSize(c, args.Path)
|
||||
if err != nil {
|
||||
return proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
if size > proto.FSFileCap {
|
||||
return proto.TaskResult{Error: "файл больше 512 МБ"}
|
||||
}
|
||||
s, err := a.sshfsSession(c)
|
||||
if err != nil {
|
||||
return proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
defer s.Close()
|
||||
out, _ := s.StdoutPipe()
|
||||
if err := s.Start(posix("cat -- " + shq(args.Path))); err != nil {
|
||||
return proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
// Read in order from cat, posted to the server a few at a time.
|
||||
sizeHdr := strconv.FormatInt(size, 10)
|
||||
n := pieceCount(size, args.Chunk)
|
||||
pctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
once sync.Once
|
||||
first error
|
||||
)
|
||||
fail := func(err error) { once.Do(func() { first = err; cancel() }) }
|
||||
sem := make(chan struct{}, xferWorkers)
|
||||
for i := 0; i < n && pctx.Err() == nil; i++ {
|
||||
buf := make([]byte, min(int64(args.Chunk), size-int64(i)*int64(args.Chunk)))
|
||||
if _, err := io.ReadFull(out, buf); err != nil {
|
||||
fail(errors.New("файл изменился во время чтения: " + err.Error()))
|
||||
break
|
||||
}
|
||||
select {
|
||||
case sem <- struct{}{}:
|
||||
case <-pctx.Done():
|
||||
}
|
||||
if pctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
wg.Add(1)
|
||||
go func(i int, buf []byte) {
|
||||
defer func() { <-sem; wg.Done() }()
|
||||
err := a.xferRequest(pctx, func(ctx context.Context) (*http.Request, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.xferURL(args.Xfer, i), bytes.NewReader(buf))
|
||||
if err == nil {
|
||||
req.Header.Set("X-Size", sizeHdr)
|
||||
req.Header.Set("Content-Type", "application/octet-stream")
|
||||
}
|
||||
return req, err
|
||||
}, func(*http.Response) error { return nil })
|
||||
if err != nil {
|
||||
fail(errors.New("передача прервана: " + err.Error()))
|
||||
}
|
||||
}(i, buf)
|
||||
}
|
||||
wg.Wait()
|
||||
if first == nil && ctx.Err() != nil {
|
||||
first = ctx.Err()
|
||||
}
|
||||
if first != nil {
|
||||
return proto.TaskResult{Error: first.Error()}
|
||||
}
|
||||
// All of it is read; the session closes (deferred) without waiting on a
|
||||
// cat that may have more to say.
|
||||
data, _ := json.Marshal(map[string]any{"path": args.Path, "size": size})
|
||||
return proto.TaskResult{OK: true, Data: data}
|
||||
}
|
||||
|
||||
// doSSHFSPut writes a file the browser uploads onto the host, through a file
|
||||
// beside it.
|
||||
func (a *Agent) doSSHFSPut(ctx context.Context, env proto.TaskEnvelope) proto.TaskResult {
|
||||
var args proto.FSXferArgs
|
||||
if err := json.Unmarshal(env.Args, &args); err != nil || args.Chunk <= 0 || args.Size < 0 || args.Size > proto.FSFileCap {
|
||||
return proto.TaskResult{Error: "плохой запрос"}
|
||||
}
|
||||
c, err := sshfsGet(args.Conn)
|
||||
if err != nil {
|
||||
return proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
defer c.hold()()
|
||||
s, err := a.sshfsSession(c)
|
||||
if err != nil {
|
||||
return proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
defer s.Close()
|
||||
in, _ := s.StdinPipe()
|
||||
var errb bytes.Buffer
|
||||
s.Stderr = &limitBuf{b: &errb, max: 4096}
|
||||
if err := s.Start(posix(writeCommand(args.Path))); err != nil {
|
||||
return proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
// Pieces come in a few ahead; stdin takes them in order. ack: how many
|
||||
// are written, for the server to let go.
|
||||
n := pieceCount(args.Size, args.Chunk)
|
||||
var written atomic.Int64
|
||||
err = ordered(ctx, n, xferWorkers, func(ctx context.Context, i int) ([]byte, error) {
|
||||
want := min(int64(args.Chunk), args.Size-int64(i)*int64(args.Chunk))
|
||||
var data []byte
|
||||
err := a.xferRequest(ctx, func(ctx context.Context) (*http.Request, error) {
|
||||
return http.NewRequestWithContext(ctx, http.MethodGet, a.xferURL(args.Xfer, i)+"?ack="+strconv.FormatInt(written.Load(), 10), nil)
|
||||
}, func(resp *http.Response) error {
|
||||
b, err := io.ReadAll(io.LimitReader(resp.Body, int64(args.Chunk)+1))
|
||||
if err == nil && int64(len(b)) != want {
|
||||
err = errors.New("кусок не той длины")
|
||||
}
|
||||
data = b
|
||||
return err
|
||||
})
|
||||
return data, err
|
||||
}, func(i int, b []byte) error {
|
||||
_, err := in.Write(b)
|
||||
written.Store(int64(i + 1))
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
_ = s.Close()
|
||||
return proto.TaskResult{Error: "загрузка прервана: " + err.Error()}
|
||||
}
|
||||
_ = in.Close()
|
||||
if err := s.Wait(); err != nil {
|
||||
msg := strings.TrimSpace(errb.String())
|
||||
if msg == "" {
|
||||
msg = err.Error()
|
||||
}
|
||||
return proto.TaskResult{Error: msg}
|
||||
}
|
||||
data, _ := json.Marshal(map[string]any{"path": args.Path, "size": args.Size})
|
||||
return proto.TaskResult{OK: true, Data: data}
|
||||
}
|
||||
|
||||
// renameTar copies a tar stream, giving each top entry named in names - and
|
||||
// everything under it - its new name: what the host's tar packs under the
|
||||
// file's own name arrives under the name chosen for it. Other entries (the
|
||||
// "._" companions a macOS tar adds) are left out.
|
||||
func renameTar(w io.Writer, r io.Reader, names map[string]string) error {
|
||||
tr, tw := tar.NewReader(r), tar.NewWriter(w)
|
||||
for {
|
||||
h, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
return tw.Close()
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n := strings.TrimPrefix(h.Name, "./")
|
||||
top, rest := n, ""
|
||||
if i := strings.IndexByte(n, '/'); i >= 0 {
|
||||
top, rest = n[:i], n[i:]
|
||||
}
|
||||
// Only base and what is under it - nothing that climbs out by "..".
|
||||
name, ok := names[top]
|
||||
if !ok || strings.Contains("/"+rest+"/", "/../") {
|
||||
continue
|
||||
}
|
||||
h.Name = name + rest
|
||||
h.Uid, h.Gid, h.Uname, h.Gname = 0, 0, "", ""
|
||||
if err := tw.WriteHeader(h); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(tw, tr); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// packRemote writes src on the connection's host as a tar stream named name,
|
||||
// counting what goes for the progress.
|
||||
func (a *Agent) packRemote(ctx context.Context, w io.Writer, c *sshfsConn, srcs, names []string, m *shipMeter) error {
|
||||
defer c.hold()()
|
||||
s, err := a.sshfsSession(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer s.Close()
|
||||
go func() { <-ctx.Done(); s.Close() }()
|
||||
out, _ := s.StdoutPipe()
|
||||
var errb bytes.Buffer
|
||||
s.Stderr = &limitBuf{b: &errb, max: 4096}
|
||||
// All from one folder: packed from there by their own names.
|
||||
dir := path.Dir(path.Clean(srcs[0]))
|
||||
rename := map[string]string{}
|
||||
cmd := "COPYFILE_DISABLE=1 tar -cf - -C " + shq(dir) + " --"
|
||||
for i, p := range srcs {
|
||||
b := path.Base(path.Clean(p))
|
||||
rename[b] = names[i]
|
||||
cmd += " " + shq(b)
|
||||
}
|
||||
if err := s.Start(posix(cmd)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := renameTar(w, &countReader{r: out, n: &m.done}, rename); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.Wait(); err != nil {
|
||||
if msg := strings.TrimSpace(errb.String()); msg != "" {
|
||||
return errors.New("tar на хосте: " + msg)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// remoteSize is about how many bytes src takes on the host, for progress.
|
||||
func (a *Agent) remoteSize(c *sshfsConn, src string) int64 {
|
||||
out, err := a.sshfsRun(c, "du -sk -- "+shq(src), nil)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
f := strings.Fields(string(out))
|
||||
if len(f) == 0 {
|
||||
return 0
|
||||
}
|
||||
kb, _ := strconv.ParseInt(f[0], 10, 64)
|
||||
return kb * 1024
|
||||
}
|
||||
|
||||
// unpackRemote puts a tar stream into dir on the connection's host, by the
|
||||
// conflict rule: tar keeps or replaces what is there, an "error" rule
|
||||
// refuses when name is there already.
|
||||
func (a *Agent) unpackRemote(c *sshfsConn, r io.Reader, dir string, names []string, conflict string) (skipped int, err error) {
|
||||
defer c.hold()()
|
||||
base := strings.TrimSuffix(dir, "/") + "/"
|
||||
check := ""
|
||||
for _, n := range names {
|
||||
check += "test -e " + shq(base+n) + " -o -L " + shq(base+n) + " && echo " + shq("LPRA_EXISTS "+n) + "; "
|
||||
}
|
||||
out, _ := a.sshfsRun(c, check+"true", nil)
|
||||
keep := map[string]string{}
|
||||
var put []string
|
||||
for _, n := range names {
|
||||
if strings.Contains(string(out), "LPRA_EXISTS "+n+"\n") {
|
||||
switch conflict {
|
||||
case "skip":
|
||||
// Left as it was, as on a machine.
|
||||
skipped++
|
||||
continue
|
||||
case "replace":
|
||||
default:
|
||||
return 0, errors.New(base + n + " уже существует")
|
||||
}
|
||||
}
|
||||
keep[n] = n
|
||||
put = append(put, n)
|
||||
}
|
||||
if len(put) == 0 {
|
||||
return skipped, nil
|
||||
}
|
||||
// Only the items sent (and not kept back) reach the host's tar.
|
||||
pr, pw := io.Pipe()
|
||||
go func() { pw.CloseWithError(renameTar(pw, r, keep)) }()
|
||||
defer pr.Close()
|
||||
_, err = a.sshfsRun(c, unpackCommand(dir, put, conflict), pr)
|
||||
return skipped, err
|
||||
}
|
||||
|
||||
// unpackCommand unpacks a tar stream of names into dir; "replace" removes
|
||||
// what is there first, so each thing is replaced whole as on a machine.
|
||||
func unpackCommand(dir string, names []string, conflict string) string {
|
||||
cmd := "mkdir -p -- " + shq(dir) + " && cd -- " + shq(dir) + " && tar -xf -"
|
||||
if conflict == "replace" {
|
||||
rm := "rm -rf --"
|
||||
for _, n := range names {
|
||||
rm += " " + shq(strings.TrimSuffix(dir, "/")+"/"+n)
|
||||
}
|
||||
cmd = rm + " && " + cmd
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseStatLine(t *testing.T) {
|
||||
// statCommand prints: type|size|mtime|mode|name
|
||||
e, ok := parseStatLine("directory|4096|1760000000|drwxr-xr-x|my dir")
|
||||
if !ok || !e.IsDir || e.Name != "my dir" || e.MTime != 1760000000 || e.Mode != "drwxr-xr-x" {
|
||||
t.Fatalf("dir: %+v %v", e, ok)
|
||||
}
|
||||
e, ok = parseStatLine("regular file|12|1760000001|-rw-r--r--|a|b.txt")
|
||||
if !ok || e.IsDir || e.Size != 12 || e.Name != "a|b.txt" {
|
||||
t.Fatalf("file with | in name: %+v %v", e, ok)
|
||||
}
|
||||
if _, ok := parseStatLine("garbage"); ok {
|
||||
t.Fatal("garbage parsed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatCommandQuotes(t *testing.T) {
|
||||
for _, fl := range []string{"gnu", "bsd"} {
|
||||
c := statCommand(fl, "/srv/it's here")
|
||||
if !strings.Contains(c, `'/srv/it'\''s here/'`) {
|
||||
t.Errorf("%s: dir not quoted: %s", fl, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLsLine(t *testing.T) {
|
||||
e, ok := parseLsLine("-rw-r--r-- 1 root root 1234 Oct 15 10:00 my file.txt")
|
||||
if !ok || e.Name != "my file.txt" || e.Size != 1234 || e.IsDir {
|
||||
t.Fatalf("file: %+v %v", e, ok)
|
||||
}
|
||||
e, ok = parseLsLine("lrwxrwxrwx 1 root root 7 Oct 15 10:00 link -> target")
|
||||
if !ok || e.Name != "link" {
|
||||
t.Fatalf("link: %+v %v", e, ok)
|
||||
}
|
||||
if _, ok := parseLsLine("drwxr-xr-x 2 root root 4096 Oct 15 10:00 .."); ok {
|
||||
t.Fatal(".. listed")
|
||||
}
|
||||
if _, ok := parseLsLine("total 8"); ok {
|
||||
t.Fatal("total parsed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenameTarEntries(t *testing.T) {
|
||||
var src bytes.Buffer
|
||||
tw := tar.NewWriter(&src)
|
||||
for _, n := range []string{"old/", "old/a.txt", "old/sub/b"} {
|
||||
typ := byte(tar.TypeReg)
|
||||
if strings.HasSuffix(n, "/") {
|
||||
typ = tar.TypeDir
|
||||
}
|
||||
_ = tw.WriteHeader(&tar.Header{Name: n, Mode: 0o644, Typeflag: typ})
|
||||
}
|
||||
_ = tw.Close()
|
||||
var out bytes.Buffer
|
||||
if err := renameTar(&out, &src, map[string]string{"old": "new"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tr := tar.NewReader(&out)
|
||||
var names []string
|
||||
for {
|
||||
h, err := tr.Next()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
names = append(names, h.Name)
|
||||
}
|
||||
if strings.Join(names, ",") != "new/,new/a.txt,new/sub/b" {
|
||||
t.Fatalf("names: %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
// A macOS host's tar packs "._name" companions with the file's attributes;
|
||||
// they are not the file and must not land under its name.
|
||||
func TestRenameTarDropsOtherEntries(t *testing.T) {
|
||||
var src bytes.Buffer
|
||||
tw := tar.NewWriter(&src)
|
||||
for _, e := range []struct{ n, body string }{{"./._r.txt", "attrs"}, {"./r.txt", "data"}} {
|
||||
_ = tw.WriteHeader(&tar.Header{Name: e.n, Mode: 0o644, Typeflag: tar.TypeReg, Size: int64(len(e.body))})
|
||||
_, _ = tw.Write([]byte(e.body))
|
||||
}
|
||||
_ = tw.Close()
|
||||
var out bytes.Buffer
|
||||
if err := renameTar(&out, &src, map[string]string{"r.txt": "new.txt"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tr := tar.NewReader(&out)
|
||||
var got []string
|
||||
for {
|
||||
h, err := tr.Next()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
b, _ := io.ReadAll(tr)
|
||||
got = append(got, h.Name+"="+string(b))
|
||||
}
|
||||
if strings.Join(got, ",") != "new.txt=data" {
|
||||
t.Fatalf("entries: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// zip on an SSH host: from the items' folder, by their own names, quoted.
|
||||
func TestZipCommand(t *testing.T) {
|
||||
cmd, err := zipCommand([]string{"/srv/a b", "/srv/it's"}, "/tmp/x.zip")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := `cd -- '/srv' && if command -v zip >/dev/null 2>&1; then zip -r -q -y '/tmp/x.zip' './a b' './it'\''s'; ` +
|
||||
`else python3 -m zipfile -c '/tmp/x.zip' './a b' './it'\''s'; fi`
|
||||
if cmd != want {
|
||||
t.Fatalf("got %s\nwant %s", cmd, want)
|
||||
}
|
||||
if _, err := zipCommand([]string{"/a/x", "/b/y"}, "/t.zip"); err == nil {
|
||||
t.Fatal("items of two folders: want an error")
|
||||
}
|
||||
if _, err := zipCommand(nil, "/t.zip"); err == nil {
|
||||
t.Fatal("no items: want an error")
|
||||
}
|
||||
if got := unzipCommand("/srv/a.zip", ""); got != `if command -v unzip >/dev/null 2>&1; then unzip -o -q '/srv/a.zip' -d '/srv'; else python3 -m zipfile -e '/srv/a.zip' '/srv'; fi` {
|
||||
t.Fatalf("unzip: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A connection busy with a long transfer is not idle, however long ago it
|
||||
// was opened; once the transfer ends the idle clock starts again.
|
||||
func TestSweepSparesBusyConn(t *testing.T) {
|
||||
c := &sshfsConn{last: time.Now().Add(-2 * sshfsIdle)}
|
||||
release := c.hold()
|
||||
if c.idle(time.Now()) {
|
||||
t.Fatal("a busy connection was taken for idle")
|
||||
}
|
||||
release()
|
||||
if c.idle(time.Now()) {
|
||||
t.Fatal("just released: not idle yet")
|
||||
}
|
||||
if !c.idle(time.Now().Add(2 * sshfsIdle)) {
|
||||
t.Fatal("unused for long: idle")
|
||||
}
|
||||
}
|
||||
|
||||
// A file's size is its target's when it is a link: what cat will read.
|
||||
func TestSizeCommandFollowsLinks(t *testing.T) {
|
||||
for _, f := range []string{"gnu", "bsd"} {
|
||||
if cmd := sizeCommand(f, "/x"); !strings.Contains(cmd, "stat -L ") {
|
||||
t.Fatalf("%s: %s", f, cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing of a tar stream lands outside name: other entries and ".." paths
|
||||
// are dropped.
|
||||
func TestRenameTarKeepsInsideName(t *testing.T) {
|
||||
var src bytes.Buffer
|
||||
tw := tar.NewWriter(&src)
|
||||
for _, n := range []string{"d/a", "d/../../etc/x", ".ssh/authorized_keys", "d/b"} {
|
||||
_ = tw.WriteHeader(&tar.Header{Name: n, Mode: 0o644, Typeflag: tar.TypeReg})
|
||||
}
|
||||
_ = tw.Close()
|
||||
var out bytes.Buffer
|
||||
if err := renameTar(&out, &src, map[string]string{"d": "d"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tr := tar.NewReader(&out)
|
||||
var got []string
|
||||
for {
|
||||
h, err := tr.Next()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
got = append(got, h.Name)
|
||||
}
|
||||
if strings.Join(got, ",") != "d/a,d/b" {
|
||||
t.Fatalf("entries: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Into an SSH host, "replace" replaces the thing whole, as on a machine.
|
||||
func TestUnpackCommandReplaces(t *testing.T) {
|
||||
cmd := unpackCommand("/srv", []string{"d"}, "replace")
|
||||
if !strings.HasPrefix(cmd, "rm -rf -- '/srv/d' && ") || strings.Contains(cmd, "--overwrite") {
|
||||
t.Fatalf("replace: %s", cmd)
|
||||
}
|
||||
if cmd := unpackCommand("/srv", []string{"d"}, "error"); cmd != "mkdir -p -- '/srv' && cd -- '/srv' && tar -xf -" {
|
||||
t.Fatalf("error: %s", cmd)
|
||||
}
|
||||
}
|
||||
|
||||
// A link to a folder lists as a folder (marked as a link), so it opens.
|
||||
func TestParseStatLineFolderLink(t *testing.T) {
|
||||
e, ok := parseStatLine("LD|symbolic link|7|1700000000|lrwxrwxrwx|www")
|
||||
if !ok || !e.IsDir || !e.Link || e.Name != "www" {
|
||||
t.Fatalf("%+v %v", e, ok)
|
||||
}
|
||||
e, ok = parseStatLine("|regular file|5|1700000000|-rw-r--r--|a|b")
|
||||
if !ok || e.IsDir || e.Name != "a|b" {
|
||||
t.Fatalf("plain: %+v %v", e, ok)
|
||||
}
|
||||
if !strings.Contains(statCommand("gnu", "/x"), `[ -L "$f" ] && [ -d "$f" ]`) {
|
||||
t.Fatal("the command does not mark folder links")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListFolderLink(t *testing.T) {
|
||||
d := t.TempDir()
|
||||
_ = os.Mkdir(filepath.Join(d, "real"), 0o755)
|
||||
_ = os.Symlink(filepath.Join(d, "real"), filepath.Join(d, "ln"))
|
||||
r, err := fsList(d)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, e := range r.Entries {
|
||||
if e.Name == "ln" && (!e.IsDir || !e.Link) {
|
||||
t.Fatalf("link: %+v", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Commands go to the host as one argument of sh -c, so the user's own login
|
||||
// shell (fish, csh) cannot read them its way.
|
||||
func TestPosixCommand(t *testing.T) {
|
||||
if got := posix(`if [ -d '/x' ]; then echo ok; fi`); got != `sh -c 'if [ -d '\''/x'\'' ]; then echo ok; fi'` {
|
||||
t.Fatalf("%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// csh expands "!" even inside single quotes: it goes out as '\!'.
|
||||
func TestPosixCommandBang(t *testing.T) {
|
||||
if got := posix(`ls .[!.]*`); got != `sh -c 'ls .['\!'.]*'` {
|
||||
t.Fatalf("%s", got)
|
||||
}
|
||||
}
|
||||
+24
-2
@@ -26,6 +26,16 @@ func (a *Agent) runTask(ctx context.Context, taskID string) {
|
||||
res = a.doFSPut(ctx, env)
|
||||
case proto.TaskFSSend:
|
||||
res = a.doFSSend(ctx, env)
|
||||
case proto.TaskSSHFSSend:
|
||||
res = a.doSSHFSSend(ctx, env)
|
||||
case proto.TaskSSHFSPut:
|
||||
res = a.doSSHFSPut(ctx, env)
|
||||
case proto.TaskSSHFSOpen, proto.TaskSSHFSClose, proto.TaskSSHFSOp:
|
||||
res = resultOf(a.sshfsTask(ctx, env))
|
||||
case proto.TaskFSShipOut:
|
||||
res = a.doShipOut(ctx, env)
|
||||
case proto.TaskFSShipIn:
|
||||
res = a.doShipIn(ctx, env)
|
||||
default:
|
||||
res = a.execTask(env)
|
||||
}
|
||||
@@ -34,7 +44,7 @@ func (a *Agent) runTask(ctx context.Context, taskID string) {
|
||||
|
||||
func (a *Agent) fetchTask(ctx context.Context, taskID string) (proto.TaskEnvelope, error) {
|
||||
var env proto.TaskEnvelope
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, a.cfg.Host+"/api/agent/task/"+taskID, nil)
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, a.host()+"/api/agent/task/"+taskID, nil)
|
||||
a.authHeaders(req)
|
||||
resp, err := a.http.Do(req)
|
||||
if err != nil {
|
||||
@@ -50,7 +60,7 @@ func (a *Agent) fetchTask(ctx context.Context, taskID string) (proto.TaskEnvelop
|
||||
|
||||
func (a *Agent) postResult(ctx context.Context, taskID string, res proto.TaskResult) {
|
||||
body, _ := json.Marshal(res)
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, a.cfg.Host+"/api/agent/task/"+taskID+"/result", bytes.NewReader(body))
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, a.host()+"/api/agent/task/"+taskID+"/result", bytes.NewReader(body))
|
||||
a.authHeaders(req)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := a.http.Do(req)
|
||||
@@ -103,3 +113,15 @@ func itoa64(n int64) string {
|
||||
}
|
||||
return string(b[i:])
|
||||
}
|
||||
|
||||
// resultOf makes a task's result of what it returned.
|
||||
func resultOf(data any, err error) proto.TaskResult {
|
||||
if err != nil {
|
||||
return proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
raw, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
return proto.TaskResult{OK: true, Data: raw}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// termsBySID are the running terminals by their session, so the panel can
|
||||
// ask where one of them is.
|
||||
var termsBySID sync.Map // sid -> *PTY
|
||||
|
||||
// Cwd is the folder the terminal is in now: that of the program in front
|
||||
// (the shell, or what it runs), else of the shell itself.
|
||||
func (p *PTY) Cwd() (string, error) {
|
||||
pid := 0
|
||||
if g, err := unix.IoctlGetInt(int(p.f.Fd()), unix.TIOCGPGRP); err == nil && g > 0 {
|
||||
pid = g
|
||||
}
|
||||
if pid > 0 {
|
||||
if d, err := procCwd(pid); err == nil {
|
||||
return d, nil
|
||||
}
|
||||
}
|
||||
return procCwd(p.cmd.Process.Pid)
|
||||
}
|
||||
|
||||
// procCwd is a process's working folder: /proc on Linux, lsof elsewhere.
|
||||
func procCwd(pid int) (string, error) {
|
||||
if runtime.GOOS == "linux" {
|
||||
return os.Readlink("/proc/" + strconv.Itoa(pid) + "/cwd")
|
||||
}
|
||||
out, err := exec.Command("lsof", "-a", "-p", strconv.Itoa(pid), "-d", "cwd", "-Fn").Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, l := range strings.Split(string(out), "\n") {
|
||||
if strings.HasPrefix(l, "n/") {
|
||||
return l[1:], nil
|
||||
}
|
||||
}
|
||||
return "", errors.New("папка терминала не найдена")
|
||||
}
|
||||
|
||||
// termCwd answers the panel: where the terminal of session sid is now.
|
||||
func termCwd(sid string) (any, error) {
|
||||
v, ok := termsBySID.Load(sid)
|
||||
if !ok {
|
||||
return nil, errors.New("терминал не найден")
|
||||
}
|
||||
d, err := v.(*PTY).Cwd()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]string{"path": d}, nil
|
||||
}
|
||||
+70
-11
@@ -4,21 +4,51 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/creack/pty"
|
||||
)
|
||||
|
||||
// PTY is a pseudo-terminal running an interactive login shell.
|
||||
type PTY struct {
|
||||
cmd *exec.Cmd
|
||||
f *os.File
|
||||
cmd *exec.Cmd
|
||||
f *os.File
|
||||
exited chan struct{} // closed once the shell has exited
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
// hangupGrace is how long a shell gets after the hangup to save its history
|
||||
// and exit before it is killed.
|
||||
const hangupGrace = 3 * time.Second
|
||||
|
||||
// openPTYs are the terminals running now, to close on shutdown.
|
||||
var openPTYs sync.Map // *PTY -> struct{}
|
||||
|
||||
// CloseTerminals hangs up every open terminal and waits for them, so the
|
||||
// shells save their history before the agent exits.
|
||||
func CloseTerminals() {
|
||||
var wg sync.WaitGroup
|
||||
openPTYs.Range(func(k, _ any) bool {
|
||||
wg.Add(1)
|
||||
go func() { defer wg.Done(); k.(*PTY).Close() }()
|
||||
return true
|
||||
})
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// StartPTY launches a login shell attached to a new PTY. With user empty (or
|
||||
// equal to the agent's own user) it is the agent's shell - root by default,
|
||||
// since the agent runs as a root systemd service. Any other user is entered via
|
||||
// `su -l`, which only a root agent can do without a password.
|
||||
func StartPTY(user string) (*PTY, error) {
|
||||
// `su -l`, which only a root agent can do without a password. dir, when it
|
||||
// is a folder, is where the shell starts instead of home.
|
||||
func StartPTY(user, dir string) (*PTY, error) {
|
||||
if dir != "" && (!filepath.IsAbs(dir) || strings.ContainsAny(dir, "\x00\r\n") || !isDir(dir)) {
|
||||
dir = ""
|
||||
}
|
||||
var cmd *exec.Cmd
|
||||
env := append(os.Environ(), "TERM=xterm-256color")
|
||||
if user == "" || user == currentUser() {
|
||||
@@ -30,6 +60,9 @@ func StartPTY(user string) (*PTY, error) {
|
||||
cmd.Dir = home
|
||||
env = append(env, "HOME="+home)
|
||||
}
|
||||
if dir != "" {
|
||||
cmd.Dir = dir
|
||||
}
|
||||
} else {
|
||||
if !validUserName(user) {
|
||||
return nil, fmt.Errorf("недопустимое имя пользователя %q", user)
|
||||
@@ -45,6 +78,11 @@ func StartPTY(user string) (*PTY, error) {
|
||||
return nil, fmt.Errorf("su не найден: %w", err)
|
||||
}
|
||||
cmd = exec.Command(su, "-l", user)
|
||||
// su -l always goes home; to start elsewhere, its shell changes
|
||||
// directory first and becomes the login shell (sh and csh alike).
|
||||
if dir != "" {
|
||||
cmd = exec.Command(su, "-l", user, "-c", "cd "+shQuote(dir)+"; exec \"$SHELL\" -l")
|
||||
}
|
||||
// su -l goes to the user's home; starting there too keeps the shell
|
||||
// out of "/" should it not.
|
||||
if home := homeDir(user); isDir(home) {
|
||||
@@ -56,7 +94,10 @@ func StartPTY(user string) (*PTY, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &PTY{cmd: cmd, f: f}, nil
|
||||
p := &PTY{cmd: cmd, f: f, exited: make(chan struct{})}
|
||||
go func() { _ = cmd.Wait(); close(p.exited) }()
|
||||
openPTYs.Store(p, struct{}{})
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func isDir(p string) bool {
|
||||
@@ -78,12 +119,30 @@ func (p *PTY) Resize(cols, rows uint16) {
|
||||
_ = pty.Setsize(p.f, &pty.Winsize{Rows: rows, Cols: cols})
|
||||
}
|
||||
|
||||
// Close terminates the shell and releases the PTY.
|
||||
// Close hangs the terminal up as a closed window would: the shell gets
|
||||
// SIGHUP, saves its history and exits. One that does not within hangupGrace
|
||||
// is killed. Safe to call more than once.
|
||||
func (p *PTY) Close() error {
|
||||
err := p.f.Close()
|
||||
if p.cmd.Process != nil {
|
||||
_ = p.cmd.Process.Kill()
|
||||
}
|
||||
_ = p.cmd.Wait()
|
||||
var err error
|
||||
p.once.Do(func() {
|
||||
// Closing the master hangs up the session; the signal to the
|
||||
// shell's process group makes sure it hears, through su as well.
|
||||
err = p.f.Close()
|
||||
pid := p.cmd.Process.Pid
|
||||
_ = syscall.Kill(-pid, syscall.SIGHUP)
|
||||
select {
|
||||
case <-p.exited:
|
||||
case <-time.After(hangupGrace):
|
||||
_ = syscall.Kill(-pid, syscall.SIGKILL)
|
||||
<-p.exited
|
||||
}
|
||||
openPTYs.Delete(p)
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// shQuote puts s in single quotes for sh and csh alike, closing and
|
||||
// reopening them around each quote inside.
|
||||
func shQuote(s string) string {
|
||||
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||
)
|
||||
|
||||
// A terminal opened with a folder starts its shell there; a folder that is
|
||||
// not there leaves it at home.
|
||||
func TestStartPTYDir(t *testing.T) {
|
||||
dir, err := filepath.EvalSymlinks(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sub := filepath.Join(dir, "sub dir")
|
||||
if err := os.Mkdir(sub, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := ptyPwd(t, sub); got != sub {
|
||||
t.Fatalf("pwd = %q, want %q", got, sub)
|
||||
}
|
||||
home, _ := filepath.EvalSymlinks(homeDir(""))
|
||||
if got := ptyPwd(t, filepath.Join(dir, "missing")); got != home {
|
||||
t.Fatalf("pwd for a missing folder = %q, want home %q", got, home)
|
||||
}
|
||||
}
|
||||
|
||||
// ptyPwd opens a terminal at dir and returns where its shell is.
|
||||
func ptyPwd(t *testing.T, dir string) string {
|
||||
t.Helper()
|
||||
t.Setenv("SHELL", "/bin/sh")
|
||||
p, err := StartPTY("", dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer p.Close()
|
||||
if _, err := p.Write([]byte("echo \"@@$(pwd -P)@@\"\n")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out strings.Builder
|
||||
buf := make([]byte, 4096)
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
n, err := p.Read(buf)
|
||||
out.Write(buf[:n])
|
||||
s := out.String()
|
||||
// The echoed command line has "@@$(pwd" in it; the answer does not.
|
||||
for _, part := range strings.Split(s, "@@") {
|
||||
if strings.HasPrefix(part, "/") && strings.Contains(s, part+"@@") {
|
||||
return part
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
t.Fatalf("no pwd in %q", out.String())
|
||||
return ""
|
||||
}
|
||||
|
||||
// A terminal tells the folder its shell is in now, after a cd.
|
||||
func TestPTYCwd(t *testing.T) {
|
||||
dir, err := filepath.EvalSymlinks(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sub := filepath.Join(dir, "next one")
|
||||
if err := os.Mkdir(sub, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("SHELL", "/bin/sh")
|
||||
p, err := StartPTY("", dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer p.Close()
|
||||
go func() { _, _ = io.Copy(io.Discard, p) }()
|
||||
if _, err := p.Write([]byte("cd 'next one'\n")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got string
|
||||
for i := 0; i < 50; i++ {
|
||||
if got, err = p.Cwd(); err == nil && got == sub {
|
||||
return
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("Cwd = %q, %v; want %q", got, err, sub)
|
||||
}
|
||||
|
||||
// The panel asks for a terminal's folder by its session.
|
||||
func TestTermCwdTask(t *testing.T) {
|
||||
dir, _ := filepath.EvalSymlinks(t.TempDir())
|
||||
t.Setenv("SHELL", "/bin/sh")
|
||||
p, err := StartPTY("", dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer p.Close()
|
||||
termsBySID.Store("s1", p)
|
||||
defer termsBySID.Delete("s1")
|
||||
res, err := dispatchTask(proto.TaskEnvelope{Type: proto.TaskTermCwd, Args: []byte(`{"session":"s1"}`)})
|
||||
if err != nil || res.(map[string]string)["path"] != dir {
|
||||
t.Fatalf("term.cwd = %v, %v; want %q", res, err, dir)
|
||||
}
|
||||
if _, err := dispatchTask(proto.TaskEnvelope{Type: proto.TaskTermCwd, Args: []byte(`{"session":"nope"}`)}); err == nil {
|
||||
t.Fatal("an unknown session has a folder")
|
||||
}
|
||||
}
|
||||
@@ -26,9 +26,7 @@ func (a *Agent) runTunnel(ctx context.Context, addr string) {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
if backoff < 15*time.Second {
|
||||
backoff *= 2
|
||||
}
|
||||
backoff = min(backoff*2, retryCap)
|
||||
continue
|
||||
}
|
||||
backoff = time.Second
|
||||
@@ -96,8 +94,14 @@ func (t *tunClient) write(f tunnel.Frame) error {
|
||||
return tunnel.WriteFrame(t.conn, f)
|
||||
}
|
||||
|
||||
// tunnelSilence is how long the tunnel may say nothing before it is taken
|
||||
// for dead: the server pings every 20 s, so this is three missed pings - a
|
||||
// connection a NAT forgot while the machine slept, say.
|
||||
const tunnelSilence = 65 * time.Second
|
||||
|
||||
func (t *tunClient) readLoop() error {
|
||||
for {
|
||||
_ = t.conn.SetReadDeadline(time.Now().Add(tunnelSilence))
|
||||
f, err := tunnel.ReadFrame(t.conn)
|
||||
if err != nil {
|
||||
t.shutdown()
|
||||
@@ -207,9 +211,7 @@ func (a *Agent) runUDP(ctx context.Context, addr string) {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
if backoff < 15*time.Second {
|
||||
backoff *= 2
|
||||
}
|
||||
backoff = min(backoff*2, retryCap)
|
||||
continue
|
||||
}
|
||||
backoff = time.Second
|
||||
|
||||
+120
-27
@@ -3,31 +3,40 @@ package agent
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||
)
|
||||
|
||||
// doUpdate downloads the latest agent binary for this platform, atomically
|
||||
// replaces the running executable, and schedules a service restart. It never
|
||||
// uninstalls - only updates.
|
||||
// doUpdate downloads the latest agent binary for this platform, replaces the
|
||||
// running executable and restarts the service. It runs as root when it can:
|
||||
// the agent's own user if that is root, otherwise sudo when it needs no
|
||||
// password. It never uninstalls - only updates.
|
||||
func (a *Agent) doUpdate() proto.TaskResult {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return proto.TaskResult{Error: "не удалось определить путь бинарника: " + err.Error()}
|
||||
}
|
||||
exe, _ = filepath.EvalSymlinks(exe)
|
||||
sudo := ""
|
||||
if os.Geteuid() != 0 {
|
||||
sudo = sudoPath()
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/download/agent-%s-%s", a.cfg.Host, runtime.GOOS, runtime.GOARCH)
|
||||
url := fmt.Sprintf("%s/download/agent-%s-%s", a.host(), runtime.GOOS, runtime.GOARCH)
|
||||
req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
|
||||
resp, err := a.http.Do(req)
|
||||
resp, err := a.stream.Do(req)
|
||||
if err != nil {
|
||||
return proto.TaskResult{Error: "скачивание: " + err.Error()}
|
||||
}
|
||||
@@ -36,41 +45,125 @@ func (a *Agent) doUpdate() proto.TaskResult {
|
||||
return proto.TaskResult{Error: "скачивание: сервер вернул " + resp.Status}
|
||||
}
|
||||
|
||||
tmp := exe + ".new"
|
||||
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755)
|
||||
if err != nil {
|
||||
return proto.TaskResult{Error: "создание файла: " + err.Error()}
|
||||
// The new binary lands next to the old one, or, when root's hand is
|
||||
// needed to put it there, in a temporary folder first.
|
||||
dir := filepath.Dir(exe)
|
||||
if sudo != "" {
|
||||
dir = ""
|
||||
}
|
||||
if _, err := io.Copy(f, resp.Body); err != nil {
|
||||
f.Close()
|
||||
os.Remove(tmp)
|
||||
f, err := os.CreateTemp(dir, ".lpagent-new-*")
|
||||
if err != nil {
|
||||
return proto.TaskResult{Error: "создание файла: " + err.Error() + userNote()}
|
||||
}
|
||||
tmp := f.Name()
|
||||
defer os.Remove(tmp)
|
||||
n, err := io.Copy(f, io.LimitReader(resp.Body, 256<<20))
|
||||
if cerr := f.Close(); err == nil {
|
||||
err = cerr
|
||||
}
|
||||
if err != nil {
|
||||
return proto.TaskResult{Error: "запись: " + err.Error()}
|
||||
}
|
||||
f.Close()
|
||||
if err := checkBinary(tmp, n); err != nil {
|
||||
return proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
if err := os.Chmod(tmp, 0o755); err != nil {
|
||||
return proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
|
||||
// Atomic replace: renaming over a running executable is allowed on Linux;
|
||||
// the live process keeps the old inode until it restarts.
|
||||
if err := os.Rename(tmp, exe); err != nil {
|
||||
os.Remove(tmp)
|
||||
return proto.TaskResult{Error: "замена бинарника: " + err.Error()}
|
||||
how := "от " + currentUser()
|
||||
if sudo != "" {
|
||||
// install copies it in place under a new inode, as a rename would.
|
||||
if out, err := exec.Command(sudo, "-n", "install", "-m", "0755", tmp, exe).CombinedOutput(); err != nil {
|
||||
return proto.TaskResult{Error: "замена бинарника через sudo: " + strings.TrimSpace(string(out)+" "+err.Error())}
|
||||
}
|
||||
how = "через sudo"
|
||||
} else if err := os.Rename(tmp, exe); err != nil {
|
||||
// Renaming over a running executable is allowed; the live process
|
||||
// keeps the old inode until it restarts.
|
||||
return proto.TaskResult{Error: "замена бинарника: " + err.Error() + userNote()}
|
||||
}
|
||||
|
||||
// Restart shortly, after the result has been reported.
|
||||
go func() {
|
||||
time.Sleep(1500 * time.Millisecond)
|
||||
restartSelf()
|
||||
restartSelf(exe, sudo)
|
||||
}()
|
||||
|
||||
data, _ := json.Marshal(map[string]string{"status": "обновлено, перезапуск"})
|
||||
data, _ := json.Marshal(map[string]string{"status": "обновлено " + how + ", перезапуск"})
|
||||
return proto.TaskResult{OK: true, Data: data}
|
||||
}
|
||||
|
||||
// restartSelf restarts the agent so the new binary takes effect.
|
||||
func restartSelf() {
|
||||
if path, err := exec.LookPath("systemctl"); err == nil {
|
||||
_ = exec.Command(path, "restart", "lpagent").Start()
|
||||
return
|
||||
// sudoPath is sudo when it lets this user run commands without a password.
|
||||
func sudoPath() string {
|
||||
p, err := exec.LookPath("sudo")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
if exec.Command(p, "-n", "true").Run() != nil {
|
||||
return ""
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func userNote() string {
|
||||
if os.Geteuid() == 0 {
|
||||
return ""
|
||||
}
|
||||
return " (агент запущен от " + currentUser() + ", без root и sudo без пароля - обновите установщиком)"
|
||||
}
|
||||
|
||||
// checkBinary makes sure what came is a program for this system, not an
|
||||
// error page: a binary of a sensible size with the right header.
|
||||
func checkBinary(path string, size int64) error {
|
||||
if size < 1<<20 {
|
||||
return fmt.Errorf("скачанный файл слишком мал (%d байт) - не похоже на агента", size)
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
var magic [4]byte
|
||||
if _, err := io.ReadFull(f, magic[:]); err != nil {
|
||||
return err
|
||||
}
|
||||
ok := false
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
ok = string(magic[:]) == "\x7fELF"
|
||||
case "darwin":
|
||||
ok = magic == [4]byte{0xcf, 0xfa, 0xed, 0xfe} || magic == [4]byte{0xca, 0xfe, 0xba, 0xbe}
|
||||
default:
|
||||
ok = true
|
||||
}
|
||||
if !ok {
|
||||
return errors.New("скачанный файл - не программа для " + runtime.GOOS)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// restartSelf starts the new binary. Under systemd the service is restarted
|
||||
// (queued, so it goes on after this process is stopped); otherwise the
|
||||
// process replaces itself with the new binary.
|
||||
func restartSelf(exe, sudo string) {
|
||||
if os.Getenv("INVOCATION_ID") != "" {
|
||||
if path, err := exec.LookPath("systemctl"); err == nil {
|
||||
args := []string{"--no-block", "restart", "lpagent"}
|
||||
cmd := exec.Command(path, args...)
|
||||
if sudo != "" {
|
||||
cmd = exec.Command(sudo, append([]string{"-n", path}, args...)...)
|
||||
}
|
||||
if cmd.Run() == nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
// systemd brings a service that stopped back up (Restart=always).
|
||||
os.Exit(0)
|
||||
}
|
||||
CloseTerminals()
|
||||
if err := syscall.Exec(exe, os.Args, os.Environ()); err != nil {
|
||||
log.Printf("update: restart: %v", err)
|
||||
os.Exit(0)
|
||||
}
|
||||
// No systemd (e.g. launchd/manual run): exit so the supervisor restarts us.
|
||||
os.Exit(0)
|
||||
}
|
||||
@@ -39,6 +39,12 @@ func dispatchTask(env proto.TaskEnvelope) (any, error) {
|
||||
var a proto.SysUserAddArgs
|
||||
_ = json.Unmarshal(env.Args, &a)
|
||||
return nil, addUser(a)
|
||||
case proto.TaskDBTables, proto.TaskDBRows, proto.TaskDBSet, proto.TaskDBAdd, proto.TaskDBDel, proto.TaskDBSQL:
|
||||
return dbTask(env)
|
||||
case proto.TaskTermCwd:
|
||||
var a proto.TermCwdArgs
|
||||
_ = json.Unmarshal(env.Args, &a)
|
||||
return termCwd(a.Session)
|
||||
}
|
||||
return dispatchFS(env)
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
}
|
||||
}
|
||||
+197
-5
@@ -5,6 +5,8 @@
|
||||
// outbound requests.
|
||||
package proto
|
||||
|
||||
import "strings"
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// Control poll message types (server -> agent, returned from /api/agent/poll).
|
||||
@@ -18,8 +20,26 @@ const (
|
||||
CtrlOpenForward = "open_forward"
|
||||
// CtrlTask tells the agent to fetch and run the one-shot task TaskID.
|
||||
CtrlTask = "task"
|
||||
// CtrlOpenSSH tells the agent to open an SSH connection to SSH and bridge
|
||||
// its shell to the session id, as a terminal.
|
||||
CtrlOpenSSH = "open_ssh"
|
||||
)
|
||||
|
||||
// SSHTarget is where an SSH terminal connects and how it signs in: a
|
||||
// password, a private key (PEM or OpenSSH, with its passphrase if it has
|
||||
// one), or, with neither, the agent user's own keys in ~/.ssh.
|
||||
type SSHTarget struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port,omitempty"`
|
||||
User string `json:"user"`
|
||||
Password string `json:"password,omitempty"`
|
||||
Key string `json:"key,omitempty"`
|
||||
Passphrase string `json:"passphrase,omitempty"`
|
||||
// Saved: a host the user saved on the server; it fills the rest there and
|
||||
// never reaches an agent.
|
||||
Saved int64 `json:"saved,omitempty"`
|
||||
}
|
||||
|
||||
// EnrollRequest is sent once by a freshly installed agent.
|
||||
type EnrollRequest struct {
|
||||
Name string `json:"name"`
|
||||
@@ -38,11 +58,50 @@ type EnrollResponse struct {
|
||||
|
||||
// ControlMessage is the body returned by the control long-poll.
|
||||
type ControlMessage struct {
|
||||
Type string `json:"type"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
Port int `json:"port,omitempty"` // CtrlOpenForward: client-side port to dial
|
||||
TaskID string `json:"task_id,omitempty"` // CtrlTask
|
||||
User string `json:"user,omitempty"` // CtrlOpenTerminal: OS user for the shell ("" = the agent's own)
|
||||
Type string `json:"type"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
Port int `json:"port,omitempty"` // CtrlOpenForward: client-side port to dial
|
||||
TaskID string `json:"task_id,omitempty"` // CtrlTask
|
||||
User string `json:"user,omitempty"` // CtrlOpenTerminal: OS user for the shell ("" = the agent's own)
|
||||
Dir string `json:"dir,omitempty"` // CtrlOpenTerminal: the folder to start in ("" or missing = home)
|
||||
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).
|
||||
@@ -70,8 +129,20 @@ type InBatch struct {
|
||||
type OutMessage struct {
|
||||
Data string `json:"data,omitempty"` // base64 bytes
|
||||
Exit bool `json:"exit,omitempty"`
|
||||
// Status tells the panel how an SSH terminal's connection goes: one of
|
||||
// the SSHStatus values, with Note to show.
|
||||
Status string `json:"status,omitempty"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
// The steps of an SSH terminal's connection, as OutMessage.Status.
|
||||
const (
|
||||
SSHDial = "dial" // connecting to the host
|
||||
SSHAuth = "auth" // the host answered, signing in
|
||||
SSHOK = "ok" // signed in, the shell runs
|
||||
SSHErr = "err" // it failed; Note says why
|
||||
)
|
||||
|
||||
// --- one-shot tasks (file manager, native in the agent) ---
|
||||
|
||||
const (
|
||||
@@ -85,6 +156,9 @@ const (
|
||||
TaskFSZip = "fs.zip" // zip selected paths (native archive/zip)
|
||||
TaskFSMove = "fs.move" // move/rename
|
||||
TaskFSDownload = "fs.download" // read a whole file (larger cap) for browser download
|
||||
TaskFSStat = "fs.stat" // a file's properties: type, size, mode, owner
|
||||
TaskFSChmod = "fs.chmod" // change mode and owner, of a folder's contents too
|
||||
TaskFSCopy = "fs.copy" // copy or move, with a rule for what is already there
|
||||
TaskFSPut = "fs.put" // write a file the browser sends in pieces
|
||||
TaskFSSend = "fs.send" // send a file to the browser in pieces
|
||||
|
||||
@@ -93,6 +167,20 @@ const (
|
||||
TaskSysUserAdd = "sys.useradd" // create a new OS user
|
||||
TaskSysStat = "sys.stat" // CPU load, RAM and disk usage right now
|
||||
TaskSysSpeed = "sys.speed" // measure the link to the server (ping, down, up)
|
||||
TaskDBTables = "db.tables" // an SQLite database's tables and views
|
||||
TaskDBRows = "db.rows" // a page of a table's rows, with each row's key
|
||||
TaskDBSet = "db.set" // set one value of one row
|
||||
TaskDBAdd = "db.add" // add a row
|
||||
TaskDBDel = "db.del" // delete rows by their keys
|
||||
TaskDBSQL = "db.sql" // run a statement typed in the panel
|
||||
TaskTermCwd = "term.cwd" // the folder a terminal is in now (TermCwdArgs); result {path}
|
||||
TaskSSHFSOpen = "sshfs.open" // connect to an SSH host for a file pane; result {id, home}
|
||||
TaskSSHFSClose = "sshfs.close" // drop that connection
|
||||
TaskSSHFSOp = "sshfs.op" // a file operation on the host: list, read, write, mkdir, delete, deltree, move, copy, stat, chmod
|
||||
TaskSSHFSSend = "sshfs.send" // a file of the host to the browser in pieces (FSXferArgs with Conn)
|
||||
TaskSSHFSPut = "sshfs.put" // a file from the browser to the host in pieces
|
||||
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
|
||||
@@ -110,6 +198,18 @@ type FSXferArgs struct {
|
||||
Xfer string `json:"xfer"`
|
||||
Size int64 `json:"size"`
|
||||
Chunk int `json:"chunk"`
|
||||
// Conn is the SSH file connection the file is on (sshfs.send, sshfs.put).
|
||||
Conn string `json:"conn,omitempty"`
|
||||
}
|
||||
|
||||
// SSHFSArgs are the arguments of the sshfs.* tasks: the connection (Conn), or
|
||||
// where to connect (SSH, for open); for an operation its name (Op) and its
|
||||
// arguments (Args) in the shapes of the fs.* tasks.
|
||||
type SSHFSArgs struct {
|
||||
Conn string `json:"conn,omitempty"`
|
||||
SSH *SSHTarget `json:"ssh,omitempty"`
|
||||
Op string `json:"op,omitempty"`
|
||||
Args json.RawMessage `json:"args,omitempty"`
|
||||
}
|
||||
|
||||
// TaskEnvelope is what the agent fetches for a task id.
|
||||
@@ -146,6 +246,47 @@ type FSMoveArgs struct {
|
||||
Dst string `json:"dst"`
|
||||
}
|
||||
|
||||
// FSStatResult is returned for TaskFSStat. Mode holds the permission bits
|
||||
// with setuid, setgid and sticky (07777). Users and Groups are the names on
|
||||
// the machine, offered when choosing an owner.
|
||||
type FSStatResult struct {
|
||||
Path string `json:"path"`
|
||||
IsDir bool `json:"is_dir"`
|
||||
Link string `json:"link,omitempty"` // where a symlink points
|
||||
Size int64 `json:"size"`
|
||||
MTime int64 `json:"mtime"`
|
||||
Mode uint32 `json:"mode"`
|
||||
UID int `json:"uid"`
|
||||
GID int `json:"gid"`
|
||||
Owner string `json:"owner"`
|
||||
Group string `json:"group"`
|
||||
Users []string `json:"users"`
|
||||
Groups []string `json:"groups"`
|
||||
}
|
||||
|
||||
// FSChmodArgs are the arguments of TaskFSChmod. Mode nil, Owner or Group
|
||||
// empty leave that as it is. Recursive applies to everything inside a folder
|
||||
// as well, Only narrowing it to "files" or "dirs".
|
||||
type FSChmodArgs struct {
|
||||
Path string `json:"path"`
|
||||
Mode *uint32 `json:"mode,omitempty"`
|
||||
Owner string `json:"owner,omitempty"`
|
||||
Group string `json:"group,omitempty"`
|
||||
Recursive bool `json:"recursive,omitempty"`
|
||||
Only string `json:"only,omitempty"`
|
||||
}
|
||||
|
||||
// FSCopyArgs are the arguments of TaskFSCopy: Src goes to Dst (the full new
|
||||
// path), copied or, with Move, moved. Conflict says what to do when Dst is
|
||||
// already there: "error" (the default) stops, "replace" puts Src in its place,
|
||||
// "skip" keeps it. Folders on both sides are merged under replace and skip.
|
||||
type FSCopyArgs struct {
|
||||
Src string `json:"src"`
|
||||
Dst string `json:"dst"`
|
||||
Move bool `json:"move,omitempty"`
|
||||
Conflict string `json:"conflict,omitempty"`
|
||||
}
|
||||
|
||||
// FSEntry is one directory entry.
|
||||
type FSEntry struct {
|
||||
Name string `json:"name"`
|
||||
@@ -153,6 +294,8 @@ type FSEntry struct {
|
||||
Size int64 `json:"size"`
|
||||
Mode string `json:"mode"`
|
||||
MTime int64 `json:"mtime"`
|
||||
// Link: a symbolic link (IsDir then says where it leads: a folder).
|
||||
Link bool `json:"link,omitempty"`
|
||||
}
|
||||
|
||||
// FSListResult is returned for TaskFSList.
|
||||
@@ -215,3 +358,52 @@ type SysSpeedResult struct {
|
||||
// counted on its own side by then.
|
||||
Partial bool `json:"partial,omitempty"`
|
||||
}
|
||||
|
||||
// FSShipArgs are the arguments of TaskFSShipOut and TaskFSShipIn: Src (on the
|
||||
// sending machine) lands in Dir as Name (on the receiving one), following
|
||||
// Conflict as FSCopyArgs does. The sender packs it as a tar stream and either
|
||||
// posts it in pieces of Chunk bytes to Relay on the server, for the receiver
|
||||
// to take, or with SSH set writes it to tar on that host itself, and with
|
||||
// Move then deletes Src. Both report to Job how far they are.
|
||||
type FSShipArgs struct {
|
||||
Src string `json:"src,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Dir string `json:"dir"`
|
||||
Conflict string `json:"conflict,omitempty"`
|
||||
Move bool `json:"move,omitempty"`
|
||||
Relay string `json:"relay,omitempty"`
|
||||
Chunk int `json:"chunk,omitempty"`
|
||||
Job string `json:"job,omitempty"`
|
||||
SSH *SSHTarget `json:"ssh,omitempty"`
|
||||
// Conn: the files of this side are on the SSH host of this agent's file
|
||||
// connection (an SSH file pane), not on the machine itself.
|
||||
Conn string `json:"conn,omitempty"`
|
||||
// More: other things of Src's folder sent in the same stream under their
|
||||
// own names (several files dragged at once). Agents before 0.2.0 do not
|
||||
// know it; the server sends to them one at a time.
|
||||
More []string `json:"more,omitempty"`
|
||||
}
|
||||
|
||||
// ShipNames is what lands at the other end: Name, then More by their names.
|
||||
func (a FSShipArgs) ShipNames() []string {
|
||||
n := []string{a.Name}
|
||||
for _, p := range a.More {
|
||||
p = strings.TrimRight(p, "/")
|
||||
n = append(n, p[strings.LastIndex(p, "/")+1:])
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// TermCwdArgs names the terminal session whose folder the panel asks for.
|
||||
type TermCwdArgs struct {
|
||||
Session string `json:"session"`
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,395 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func collect(t *testing.T, cfg aiConfig) (string, error) {
|
||||
t.Helper()
|
||||
var b strings.Builder
|
||||
err := aiStream(context.Background(), cfg, "sys", []aiMsg{{Role: "user", Content: "раз"}, {Role: "assistant", Content: "два"}, {Role: "user", Content: "три"}}, func(s string) { b.WriteString(s) })
|
||||
return b.String(), err
|
||||
}
|
||||
|
||||
// Gradio (the Qwen space behind the proxy): history in, the answer streamed
|
||||
// as diffs of the chatbot, the key in X-Agent-Key.
|
||||
func TestAIGradio(t *testing.T) {
|
||||
var joined map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("X-Agent-Key") != "k" {
|
||||
http.Error(w, "no key", 403)
|
||||
return
|
||||
}
|
||||
switch r.URL.Path {
|
||||
case "/config":
|
||||
fmt.Fprint(w, `{"dependencies":[{"id":3,"api_name":"other"},{"id":0,"api_name":"chat_predict","targets":[[21,"click"]]}]}`)
|
||||
case "/gradio_api/queue/join":
|
||||
_ = json.NewDecoder(r.Body).Decode(&joined)
|
||||
fmt.Fprint(w, `{"event_id":"e"}`)
|
||||
case "/gradio_api/queue/data":
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
for _, m := range []string{
|
||||
`{"msg":"process_starts"}`,
|
||||
`{"msg":"process_generating","output":{"data":[null,null,null,null,[{"role":"user","content":"три"}],{},{}]}}`,
|
||||
`{"msg":"process_generating","output":{"data":[[],[],[],[],[["add",[3],{"role":"assistant","content":"При"}]],[],[]]}}`,
|
||||
`{"msg":"process_generating","output":{"data":[[],[],[],[],[["append",[3,"content"],"вет"]],[],[]]}}`,
|
||||
`{"msg":"process_completed","success":true,"output":{"data":[{},{},{},{},[{"role":"user","content":"три"},{"role":"assistant","content":"Привет!"}],{},{}]}}`,
|
||||
} {
|
||||
fmt.Fprintf(w, "data: %s\n\n", m)
|
||||
}
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
got, err := collect(t, aiConfig{Type: "gradio", URL: srv.URL, Key: "k"})
|
||||
if err != nil || got != "Привет!" {
|
||||
t.Fatalf("got %q, %v", got, err)
|
||||
}
|
||||
d := joined["data"].([]any)
|
||||
if joined["fn_index"].(float64) != 0 || d[0] != "три" || d[5] != "sys" || len(d[4].([]any)) != 2 {
|
||||
t.Fatalf("join: %v", joined)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIGradioError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/config":
|
||||
fmt.Fprint(w, `{"dependencies":[{"id":0,"api_name":"chat_predict"}]}`)
|
||||
case "/gradio_api/queue/join":
|
||||
fmt.Fprint(w, `{"event_id":"e"}`)
|
||||
default:
|
||||
fmt.Fprint(w, "data: {\"msg\":\"process_completed\",\"success\":false,\"output\":{\"error\":\"GPU занят\"}}\n\n")
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
if _, err := collect(t, aiConfig{Type: "gradio", URL: srv.URL}); err == nil || !strings.Contains(err.Error(), "GPU занят") {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIOpenAI(t *testing.T) {
|
||||
var body map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/chat/completions" || r.Header.Get("Authorization") != "Bearer k" {
|
||||
http.Error(w, "bad", 400)
|
||||
return
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"role\":\"assistant\"}}]}\n\ndata: {\"choices\":[{\"delta\":{\"content\":\"При\"}}]}\n\ndata: {\"choices\":[{\"delta\":{\"content\":\"вет\"}}]}\n\ndata: [DONE]\n\n")
|
||||
}))
|
||||
defer srv.Close()
|
||||
got, err := collect(t, aiConfig{Type: "openai", URL: srv.URL + "/v1", Key: "k", Model: "m"})
|
||||
if err != nil || got != "Привет" {
|
||||
t.Fatalf("got %q, %v", got, err)
|
||||
}
|
||||
msgs := body["messages"].([]any)
|
||||
if body["model"] != "m" || body["stream"] != true || len(msgs) != 4 || msgs[0].(map[string]any)["role"] != "system" {
|
||||
t.Fatalf("body: %v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIOpenAIAttachments(t *testing.T) {
|
||||
var body map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
fmt.Fprint(w, "data: [DONE]\n\n")
|
||||
}))
|
||||
defer srv.Close()
|
||||
msg := aiMsg{Role: "user", Content: "посмотри", Attachments: []aiAttachment{
|
||||
{Name: "image.png", MIME: "image/png", Data: "AQID"},
|
||||
{Name: "voice.wav", MIME: "audio/wav", Data: "BAUG"},
|
||||
{Name: "clip.mp4", MIME: "video/mp4", Data: "BwgJ"},
|
||||
}}
|
||||
if err := aiOpenAI(context.Background(), aiConfig{Type: "openai", URL: srv.URL, Model: "m"}, "sys", []aiMsg{msg}, func(string) {}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
messages := body["messages"].([]any)
|
||||
content := messages[1].(map[string]any)["content"].([]any)
|
||||
if len(content) != 4 || content[3].(map[string]any)["text"] != "посмотри" {
|
||||
t.Fatalf("content: %#v", content)
|
||||
}
|
||||
if got := content[0].(map[string]any)["image_url"].(map[string]any)["url"].(string); !strings.HasPrefix(got, "data:image/png;base64,") {
|
||||
t.Fatalf("image: %q", got)
|
||||
}
|
||||
if got := content[1].(map[string]any)["input_audio"].(map[string]any)["format"]; got != "wav" {
|
||||
t.Fatalf("audio: %#v", content[1])
|
||||
}
|
||||
video := content[2].(map[string]any)
|
||||
if video["type"] != "video_url" || video["fps"] != float64(1) || !strings.HasPrefix(video["video_url"].(map[string]any)["url"].(string), "data:video/mp4;base64,") {
|
||||
t.Fatalf("video: %#v", video)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIAnthropic(t *testing.T) {
|
||||
var body map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/messages" || r.Header.Get("x-api-key") != "k" || r.Header.Get("anthropic-version") == "" {
|
||||
http.Error(w, "bad", 400)
|
||||
return
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
fmt.Fprint(w, "event: message_start\ndata: {\"type\":\"message_start\"}\n\n"+
|
||||
"event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"При\"}}\n\n"+
|
||||
"event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"вет\"}}\n\n"+
|
||||
"event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n")
|
||||
}))
|
||||
defer srv.Close()
|
||||
got, err := collect(t, aiConfig{Type: "anthropic", URL: srv.URL, Key: "k", Model: "m"})
|
||||
if err != nil || got != "Привет" {
|
||||
t.Fatalf("got %q, %v", got, err)
|
||||
}
|
||||
if body["system"] != "sys" || len(body["messages"].([]any)) != 3 {
|
||||
t.Fatalf("body: %v", body)
|
||||
}
|
||||
}
|
||||
|
||||
// The chat is on by default, and an explicit user preference can still turn it off.
|
||||
func TestAIChatDefaultOnAndCanBeDisabled(t *testing.T) {
|
||||
s, c := newTestServer(t)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\ndata: [DONE]\n\n")
|
||||
}))
|
||||
defer srv.Close()
|
||||
_ = s.db.SetConfig(cfgAIType, "openai")
|
||||
_ = s.db.SetConfig(cfgAIURL, srv.URL)
|
||||
post := func() *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/panel/ai/chat", strings.NewReader(`{"message":"hi","context":"x"}`))
|
||||
req.AddCookie(c)
|
||||
w := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
a, _ := s.db.AdminByUsername("adm")
|
||||
state := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/panel/ai", nil)
|
||||
req.AddCookie(c)
|
||||
s.handleAIState(state, req)
|
||||
var got struct {
|
||||
On bool `json:"on"`
|
||||
}
|
||||
if err := json.Unmarshal(state.Body.Bytes(), &got); err != nil || !got.On {
|
||||
t.Fatalf("default state: %s (%v)", state.Body.String(), err)
|
||||
}
|
||||
w := post()
|
||||
b, _ := io.ReadAll(w.Body)
|
||||
if w.Code != 200 || string(b) != "ok" {
|
||||
t.Fatalf("on: %d %q", w.Code, b)
|
||||
}
|
||||
// The talk is kept: the question and the answer.
|
||||
cs := s.aiChats(a.ID)
|
||||
// Named by the question at once (the model may rename it a moment later).
|
||||
if len(cs) != 1 || cs[0].Title == "" || w.Header().Get("X-Chat-Id") != strconv.FormatInt(cs[0].ID, 10) {
|
||||
t.Fatalf("chats: %+v %q", cs, w.Header().Get("X-Chat-Id"))
|
||||
}
|
||||
if h := s.aiHistoryOf(a.ID, cs[0].ID, 10); len(h) != 2 || h[0].Content != "hi" || h[1].Content != "ok" {
|
||||
t.Fatalf("history: %+v", h)
|
||||
}
|
||||
off := httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/panel/ai/toggle", strings.NewReader(`{"on":false}`))
|
||||
req.AddCookie(c)
|
||||
s.handleAIToggle(off, req)
|
||||
state = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/panel/ai", nil)
|
||||
req.AddCookie(c)
|
||||
s.handleAIState(state, req)
|
||||
var after struct {
|
||||
On bool `json:"on"`
|
||||
}
|
||||
if err := json.Unmarshal(state.Body.Bytes(), &after); err != nil || after.On {
|
||||
t.Fatalf("disabled state: %s (%v)", state.Body.String(), err)
|
||||
}
|
||||
if w := post(); w.Code != http.StatusForbidden {
|
||||
t.Fatalf("explicit off: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIChatKeepsAttachmentTextOnly(t *testing.T) {
|
||||
s, c := newTestServer(t)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
Messages []map[string]any `json:"messages"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&in)
|
||||
content := in.Messages[len(in.Messages)-1]["content"].([]any)
|
||||
if content[0].(map[string]any)["type"] != "image_url" {
|
||||
http.Error(w, "image missing", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, "data: [DONE]\n\n")
|
||||
}))
|
||||
defer srv.Close()
|
||||
_ = s.db.SetConfig(cfgAIType, "openai")
|
||||
_ = s.db.SetConfig(cfgAIURL, srv.URL)
|
||||
a, _ := s.db.AdminByUsername("adm")
|
||||
_ = s.db.SetConfig(aiUserKey(a.ID), "1")
|
||||
chatID := s.aiNewChat(a.ID)
|
||||
s.aiAppendTo(a.ID, chatID, "user", "предыдущий вопрос")
|
||||
s.aiAppendTo(a.ID, chatID, "assistant", "ответ")
|
||||
body := fmt.Sprintf(`{"message":"посмотри","chat":%d,"attachments":[{"name":"x.png","mime":"image/png","data":"AQID"}]}`, chatID)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/panel/ai/chat", strings.NewReader(body))
|
||||
req.AddCookie(c)
|
||||
w := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("response: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
chat, _ := strconv.ParseInt(w.Header().Get("X-Chat-Id"), 10, 64)
|
||||
history := s.aiHistoryOf(a.ID, chat, 10)
|
||||
if chat != chatID || len(history) != 3 || !strings.Contains(history[2].Content, "x.png") || strings.Contains(history[2].Content, "AQID") {
|
||||
t.Fatalf("history contains wrong attachment data: %+v", history)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionAtLeast(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
v string
|
||||
want bool
|
||||
}{{"0.2.0", true}, {"0.10.1", true}, {"0.1.9", false}, {"", false}, {"dev", false}, {"1.0", true}} {
|
||||
if got := versionAtLeast(c.v, "0.2.0"); got != c.want {
|
||||
t.Errorf("%q: %v", c.v, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The window the model sees: newest first within the budget, old results
|
||||
// and answers cut down, results as the user's words, same roles merged.
|
||||
func TestAIWindow(t *testing.T) {
|
||||
big := strings.Repeat("x", 5000)
|
||||
rows := []aiMsg{
|
||||
{Role: "user", Content: "старое"},
|
||||
{Role: "assistant", Content: big},
|
||||
{Role: "result", Content: big},
|
||||
{Role: "user", Content: "дальше"},
|
||||
{Role: "assistant", Content: "ок"},
|
||||
{Role: "result", Content: "вывод"},
|
||||
}
|
||||
w := aiWindow(rows, 100000)
|
||||
if len(w) != 5 || w[0].Role != "user" || w[len(w)-1].Role != "user" {
|
||||
t.Fatalf("roles: %+v", w)
|
||||
}
|
||||
if len(w[1].Content) > 3200 || len(w[2].Content) > 1700+len("дальше")+40 {
|
||||
t.Fatalf("old not cut: %d %d", len(w[1].Content), len(w[2].Content))
|
||||
}
|
||||
if !strings.Contains(w[4].Content, "вывод") || !strings.HasPrefix(w[4].Content, "Результат") {
|
||||
t.Fatalf("last result: %q", w[4].Content)
|
||||
}
|
||||
// A small budget keeps the newest, starting with the user.
|
||||
w = aiWindow(rows, 60)
|
||||
if len(w) == 0 || w[0].Role != "user" || !strings.Contains(w[len(w)-1].Content, "вывод") {
|
||||
t.Fatalf("small: %+v", w)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripThink(t *testing.T) {
|
||||
if got := stripThink("::думаю::\nхм\n::ответ::\n391"); got != "391" {
|
||||
t.Fatalf("%q", got)
|
||||
}
|
||||
if got := stripThink("просто"); got != "просто" {
|
||||
t.Fatalf("%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Several talks: each its own messages; a new one is named by its first
|
||||
// question; deleting one leaves the others.
|
||||
func TestAIChats(t *testing.T) {
|
||||
s, _ := newTestServer(t)
|
||||
a, _ := s.db.AdminByUsername("adm")
|
||||
one := s.aiNewChat(a.ID)
|
||||
two := s.aiNewChat(a.ID)
|
||||
s.aiAppendTo(a.ID, one, "user", "первый вопрос про nginx и его настройки, длинный-длинный")
|
||||
s.aiAppendTo(a.ID, two, "user", "второй")
|
||||
if h := s.aiHistoryOf(a.ID, one, 10); len(h) != 1 || h[0].Content[:5] != "перв"[:5] {
|
||||
t.Fatalf("one: %+v", h)
|
||||
}
|
||||
list := s.aiChats(a.ID)
|
||||
if len(list) != 2 || list[0].ID != two || !strings.HasPrefix(list[1].Title, "первый вопрос") || len([]rune(list[1].Title)) > 41 {
|
||||
t.Fatalf("list: %+v", list)
|
||||
}
|
||||
s.aiDeleteChat(a.ID, one)
|
||||
if list = s.aiChats(a.ID); len(list) != 1 || len(s.aiHistoryOf(a.ID, one, 10)) != 0 {
|
||||
t.Fatalf("after delete: %+v", list)
|
||||
}
|
||||
// Another user sees none of them and cannot write into them.
|
||||
if len(s.aiChats(a.ID+1)) != 0 {
|
||||
t.Fatal("another user sees the talks")
|
||||
}
|
||||
s.aiAppendTo(a.ID+1, two, "user", "чужое")
|
||||
if len(s.aiHistoryOf(a.ID, two, 10)) != 1 {
|
||||
t.Fatal("another user wrote into the talk")
|
||||
}
|
||||
}
|
||||
|
||||
// The thinking is kept with the answer (and how long it took), so it shows
|
||||
// again later; the model never gets it back.
|
||||
func TestAIThinkingKept(t *testing.T) {
|
||||
s, c := newTestServer(t)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fl := w.(http.Flusher)
|
||||
fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"content\":\"::думаю::\\nхм\\n\"}}]}\n\n")
|
||||
fl.Flush()
|
||||
time.Sleep(1100 * time.Millisecond)
|
||||
fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"content\":\"::ответ::\\n391\"}}]}\n\ndata: [DONE]\n\n")
|
||||
}))
|
||||
defer srv.Close()
|
||||
_ = s.db.SetConfig(cfgAIType, "openai")
|
||||
_ = s.db.SetConfig(cfgAIURL, srv.URL)
|
||||
a, _ := s.db.AdminByUsername("adm")
|
||||
_ = s.db.SetConfig(aiUserKey(a.ID), "1")
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/panel/ai/chat", strings.NewReader(`{"message":"17*23","think":true}`))
|
||||
req.AddCookie(c)
|
||||
w := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(w, req)
|
||||
chat, _ := strconv.ParseInt(w.Header().Get("X-Chat-Id"), 10, 64)
|
||||
h := s.aiHistoryOf(a.ID, chat, 10)
|
||||
if len(h) != 2 || !strings.Contains(h[1].Content, "::думаю::") || h[1].ThinkMS < 1000 {
|
||||
t.Fatalf("kept: %+v", h)
|
||||
}
|
||||
if win := aiWindow(h, 10000); strings.Contains(win[len(win)-1].Content, "хм") {
|
||||
t.Fatalf("the model gets the thinking back: %+v", win)
|
||||
}
|
||||
}
|
||||
|
||||
// A new chat is named by the model: its first question in two or three
|
||||
// words, asked aside so the answer does not wait.
|
||||
func TestAIChatTitle(t *testing.T) {
|
||||
s, c := newTestServer(t)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
Messages []aiMsg `json:"messages"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&in)
|
||||
reply := "ok"
|
||||
if strings.Contains(in.Messages[0].Content, "название") {
|
||||
reply = "«Выбор поисковика.»"
|
||||
}
|
||||
b, _ := json.Marshal(reply)
|
||||
fmt.Fprintf(w, "data: {\"choices\":[{\"delta\":{\"content\":%s}}]}\n\ndata: [DONE]\n\n", b)
|
||||
}))
|
||||
defer srv.Close()
|
||||
_ = s.db.SetConfig(cfgAIType, "openai")
|
||||
_ = s.db.SetConfig(cfgAIURL, srv.URL)
|
||||
a, _ := s.db.AdminByUsername("adm")
|
||||
_ = s.db.SetConfig(aiUserKey(a.ID), "1")
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/panel/ai/chat", strings.NewReader(`{"message":"какой поисковик ты используешь сейчас"}`))
|
||||
req.AddCookie(c)
|
||||
w := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(w, req)
|
||||
for i := 0; i < 50; i++ {
|
||||
if cs := s.aiChats(a.ID); len(cs) == 1 && cs[0].Title == "Выбор поисковика" {
|
||||
return
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("title: %+v", s.aiChats(a.ID))
|
||||
}
|
||||
+51
-11
@@ -3,6 +3,7 @@ package server
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
lpra "github.com/x-grk/longpolling-remote-access"
|
||||
@@ -14,9 +15,11 @@ const (
|
||||
cfgEnrollSecret = "enroll_secret"
|
||||
cfgSetupDone = "setup_done"
|
||||
cfgInstallCode = "install_code"
|
||||
cfgInstallLen = "install_code_len"
|
||||
cfgUfwEnabled = "ufw_enabled"
|
||||
cfgTunnelHost = "tunnel_host"
|
||||
cfgTunnelPort = "tunnel_port"
|
||||
cfgDirectAddr = "direct_address"
|
||||
)
|
||||
|
||||
// DefaultHostAddress is the public URL agents dial by default. It is baked into
|
||||
@@ -52,7 +55,7 @@ func (db *DB) ensureDefaults() error {
|
||||
return err
|
||||
}
|
||||
if code == "" {
|
||||
if err := db.SetConfig(cfgInstallCode, shortCode()); err != nil {
|
||||
if err := db.SetConfig(cfgInstallCode, shortCode(db.InstallCodeLen())); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -67,6 +70,23 @@ func (db *DB) HostAddress() string {
|
||||
return v
|
||||
}
|
||||
|
||||
// DirectAddress is the server's direct URL, one that is not behind a proxy
|
||||
// like Cloudflare: the agents' fallback, and a way to install when the main
|
||||
// address is out of reach. Empty when not set.
|
||||
func (db *DB) DirectAddress() string {
|
||||
v, _ := db.GetConfig(cfgDirectAddr)
|
||||
return v
|
||||
}
|
||||
|
||||
// directHost is the direct address's host name, without scheme and port.
|
||||
func directHost(addr string) string {
|
||||
u, err := url.Parse(addr)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return u.Hostname()
|
||||
}
|
||||
|
||||
func (db *DB) EnrollSecret() string {
|
||||
v, _ := db.GetConfig(cfgEnrollSecret)
|
||||
return v
|
||||
@@ -125,16 +145,36 @@ func randToken() string {
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// shortCode returns a short, URL-safe, typo-resistant code for install links.
|
||||
// It avoids ambiguous characters (0/O, 1/l/I) so it is easy to read and type.
|
||||
func shortCode() string {
|
||||
const alphabet = "abcdefghjkmnpqrstuvwxyz23456789"
|
||||
b := make([]byte, 7)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic("crypto/rand failed: " + err.Error())
|
||||
// Install code length: 3 by default, up to 24.
|
||||
const (
|
||||
installCodeMin = 3
|
||||
installCodeMax = 24
|
||||
installCodeDef = 3
|
||||
)
|
||||
|
||||
// InstallCodeLen is how many characters a new install code has.
|
||||
func (db *DB) InstallCodeLen() int {
|
||||
v, _ := db.GetConfig(cfgInstallLen)
|
||||
if n, err := strconv.Atoi(v); err == nil && n >= installCodeMin && n <= installCodeMax {
|
||||
return n
|
||||
}
|
||||
for i := range b {
|
||||
b[i] = alphabet[int(b[i])%len(alphabet)]
|
||||
return installCodeDef
|
||||
}
|
||||
|
||||
// shortCode returns a random code of n English letters and digits for the
|
||||
// /i/<code> install link. Every character is equally likely.
|
||||
func shortCode(n int) string {
|
||||
const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
out := make([]byte, 0, n)
|
||||
b := make([]byte, 1)
|
||||
for len(out) < n {
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic("crypto/rand failed: " + err.Error())
|
||||
}
|
||||
// 248 = 4 * 62: drop the rest, so no character comes up more often.
|
||||
if b[0] < 248 {
|
||||
out = append(out, alphabet[int(b[0])%len(alphabet)])
|
||||
}
|
||||
}
|
||||
return string(b)
|
||||
return string(out)
|
||||
}
|
||||
+294
-27
@@ -11,7 +11,8 @@ import (
|
||||
|
||||
// DB wraps the SQLite connection and all queries used by the server.
|
||||
type DB struct {
|
||||
sql *sql.DB
|
||||
sql *dbx
|
||||
path string // the file; the key of saved secrets sits beside it
|
||||
}
|
||||
|
||||
// OpenDB opens (creating if needed) the SQLite database at path and runs
|
||||
@@ -30,7 +31,7 @@ func OpenDB(path string) (*DB, error) {
|
||||
if err := sdb.Ping(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db := &DB{sql: sdb}
|
||||
db := &DB{sql: &dbx{DB: sdb}, path: path}
|
||||
if err := db.migrate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -85,6 +86,16 @@ CREATE TABLE IF NOT EXISTS ufw_rules (
|
||||
created_at INTEGER NOT NULL,
|
||||
UNIQUE(port, proto)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS admin_keys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
admin_id INTEGER NOT NULL REFERENCES admins(id) ON DELETE CASCADE,
|
||||
cred_id BLOB UNIQUE NOT NULL,
|
||||
public_key BLOB NOT NULL,
|
||||
sign_count INTEGER NOT NULL DEFAULT 0,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
last_used INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS forward_rules (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
agent_id TEXT NOT NULL,
|
||||
@@ -107,11 +118,41 @@ CREATE TABLE IF NOT EXISTS forward_rules (
|
||||
`ALTER TABLE agents ADD COLUMN login_user TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE forward_rules ADD COLUMN no_ufw INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE forward_rules ADD COLUMN label TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE admins ADD COLUMN password_off INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE agents ADD COLUMN transport TEXT NOT NULL DEFAULT 'auto'`,
|
||||
`ALTER TABLE admins ADD COLUMN pin_hash TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE admins ADD COLUMN role TEXT NOT NULL DEFAULT 'admin'`,
|
||||
`ALTER TABLE admins ADD COLUMN pw_locked INTEGER NOT NULL DEFAULT 0`,
|
||||
`CREATE TABLE IF NOT EXISTS ai_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
admin_id INTEGER NOT NULL REFERENCES admins(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL, content TEXT NOT NULL, at INTEGER NOT NULL)`,
|
||||
`CREATE INDEX IF NOT EXISTS ai_messages_admin ON ai_messages(admin_id, id)`,
|
||||
`ALTER TABLE ai_messages ADD COLUMN chat_id INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE ai_messages ADD COLUMN think_ms INTEGER NOT NULL DEFAULT 0`,
|
||||
`CREATE TABLE IF NOT EXISTS ai_chats (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
admin_id INTEGER NOT NULL REFERENCES admins(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL, created INTEGER NOT NULL, updated INTEGER NOT NULL)`,
|
||||
`CREATE INDEX IF NOT EXISTS ai_messages_chat ON ai_messages(chat_id, id)`,
|
||||
`CREATE TABLE IF NOT EXISTS ssh_hosts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
admin_id INTEGER NOT NULL REFERENCES admins(id) ON DELETE CASCADE,
|
||||
label TEXT NOT NULL, host TEXT NOT NULL, port INTEGER NOT NULL, login TEXT NOT NULL,
|
||||
auth TEXT NOT NULL, secret TEXT NOT NULL)`,
|
||||
`CREATE TABLE IF NOT EXISTS admin_agents (
|
||||
admin_id INTEGER NOT NULL REFERENCES admins(id) ON DELETE CASCADE,
|
||||
agent_id TEXT NOT NULL,
|
||||
PRIMARY KEY (admin_id, agent_id))`,
|
||||
} {
|
||||
if _, err := db.sql.Exec(alter); err != nil && !strings.Contains(err.Error(), "duplicate column") {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// ssh_hosts.user (a word PostgreSQL keeps for itself) became login.
|
||||
if !db.sql.pg {
|
||||
_, _ = db.sql.Exec(`ALTER TABLE ssh_hosts RENAME COLUMN user TO login`)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -142,24 +183,52 @@ func (db *DB) CountAdmins() (int, error) {
|
||||
}
|
||||
|
||||
func (db *DB) CreateAdmin(username, passHash string) error {
|
||||
_, err := db.sql.Exec(
|
||||
`INSERT INTO admins(username, pass_hash, created_at) VALUES(?, ?, ?)`,
|
||||
username, passHash, time.Now().Unix())
|
||||
_, err := db.CreateUser(username, passHash, RoleAdmin)
|
||||
return err
|
||||
}
|
||||
|
||||
// Admin is one row of the admins table.
|
||||
// CreateUser adds a panel user with the role given and returns its id.
|
||||
func (db *DB) CreateUser(username, passHash, role string) (int64, error) {
|
||||
return db.sql.insert(
|
||||
`INSERT INTO admins(username, pass_hash, created_at, role) VALUES(?, ?, ?, ?)`,
|
||||
username, passHash, time.Now().Unix(), role)
|
||||
}
|
||||
|
||||
// Roles of panel users: an admin sees and runs everything; a user only the
|
||||
// machines given to them, and none of the settings.
|
||||
const (
|
||||
RoleAdmin = "admin"
|
||||
RoleUser = "user"
|
||||
)
|
||||
|
||||
// Admin is one row of the admins table: a panel user, an admin or not.
|
||||
type Admin struct {
|
||||
ID int64
|
||||
Username string
|
||||
PassHash string
|
||||
Role string
|
||||
// PwLocked keeps the user from turning the password sign-in on or off
|
||||
// themselves: an admin set it.
|
||||
PwLocked bool
|
||||
}
|
||||
|
||||
// IsAdmin reports whether the user has the run of the whole panel.
|
||||
func (a *Admin) IsAdmin() bool { return a != nil && a.Role != RoleUser }
|
||||
|
||||
const adminCols = `id, username, pass_hash, role, pw_locked`
|
||||
|
||||
func scanAdmin(row interface{ Scan(...any) error }) (*Admin, error) {
|
||||
a := &Admin{}
|
||||
var locked int
|
||||
if err := row.Scan(&a.ID, &a.Username, &a.PassHash, &a.Role, &locked); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.PwLocked = locked == 1
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (db *DB) AdminByUsername(username string) (*Admin, error) {
|
||||
a := &Admin{}
|
||||
err := db.sql.QueryRow(
|
||||
`SELECT id, username, pass_hash FROM admins WHERE username = ?`, username).
|
||||
Scan(&a.ID, &a.Username, &a.PassHash)
|
||||
a, err := scanAdmin(db.sql.QueryRow(`SELECT `+adminCols+` FROM admins WHERE username = ?`, username))
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -167,27 +236,215 @@ func (db *DB) AdminByUsername(username string) (*Admin, error) {
|
||||
}
|
||||
|
||||
func (db *DB) ListAdmins() ([]Admin, error) {
|
||||
rows, err := db.sql.Query(`SELECT id, username FROM admins ORDER BY username`)
|
||||
rows, err := db.sql.Query(`SELECT ` + adminCols + ` FROM admins ORDER BY role, username`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Admin
|
||||
for rows.Next() {
|
||||
var a Admin
|
||||
if err := rows.Scan(&a.ID, &a.Username); err != nil {
|
||||
a, err := scanAdmin(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, a)
|
||||
out = append(out, *a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// CountRole is how many users have the role.
|
||||
func (db *DB) CountRole(role string) int {
|
||||
var n int
|
||||
_ = db.sql.QueryRow(`SELECT COUNT(*) FROM admins WHERE role = ?`, role).Scan(&n)
|
||||
return n
|
||||
}
|
||||
|
||||
func (db *DB) SetUserRole(id int64, role string) error {
|
||||
_, err := db.sql.Exec(`UPDATE admins SET role = ? WHERE id = ?`, role, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) SetPwLocked(id int64, locked bool) error {
|
||||
_, err := db.sql.Exec(`UPDATE admins SET pw_locked = ? WHERE id = ?`, boolInt(locked), id)
|
||||
return err
|
||||
}
|
||||
|
||||
// UserAgents are the machines a user may use.
|
||||
func (db *DB) UserAgents(adminID int64) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
rows, err := db.sql.Query(`SELECT agent_id FROM admin_agents WHERE admin_id = ?`, adminID)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if rows.Scan(&id) == nil {
|
||||
out[id] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SetUserAgents gives the user exactly these machines.
|
||||
func (db *DB) SetUserAgents(adminID int64, agentIDs []string) error {
|
||||
tx, err := db.sql.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.Exec(`DELETE FROM admin_agents WHERE admin_id = ?`, adminID); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, id := range agentIDs {
|
||||
if _, err := tx.Exec(`INSERT OR IGNORE INTO admin_agents(admin_id, agent_id) VALUES(?, ?)`, adminID, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// CanUseAgent reports whether the user may use the machine.
|
||||
func (db *DB) CanUseAgent(a *Admin, agentID string) bool {
|
||||
if a == nil {
|
||||
return false
|
||||
}
|
||||
if a.IsAdmin() {
|
||||
return true
|
||||
}
|
||||
var n int
|
||||
_ = db.sql.QueryRow(`SELECT COUNT(*) FROM admin_agents WHERE admin_id = ? AND agent_id = ?`, a.ID, agentID).Scan(&n)
|
||||
return n > 0
|
||||
}
|
||||
|
||||
func (db *DB) DeleteAdmin(id int64) error {
|
||||
_, err := db.sql.Exec(`DELETE FROM admins WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// AdminKey is a passkey an admin signs in with.
|
||||
type AdminKey struct {
|
||||
ID int64
|
||||
AdminID int64
|
||||
CredID []byte
|
||||
PublicKey []byte // PKIX DER
|
||||
SignCount uint32
|
||||
Name string
|
||||
CreatedAt int64
|
||||
LastUsed int64
|
||||
}
|
||||
|
||||
func (db *DB) AdminByID(id int64) (*Admin, error) {
|
||||
a, err := scanAdmin(db.sql.QueryRow(`SELECT `+adminCols+` FROM admins WHERE id = ?`, id))
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return a, err
|
||||
}
|
||||
|
||||
func (db *DB) AddAdminKey(k AdminKey) error {
|
||||
_, err := db.sql.Exec(
|
||||
`INSERT INTO admin_keys(admin_id, cred_id, public_key, sign_count, name, created_at) VALUES(?, ?, ?, ?, ?, ?)`,
|
||||
k.AdminID, k.CredID, k.PublicKey, k.SignCount, k.Name, time.Now().Unix())
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) ListAdminKeys(adminID int64) ([]AdminKey, error) {
|
||||
rows, err := db.sql.Query(
|
||||
`SELECT id, admin_id, cred_id, public_key, sign_count, name, created_at, last_used
|
||||
FROM admin_keys WHERE admin_id = ? ORDER BY id`, adminID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []AdminKey
|
||||
for rows.Next() {
|
||||
var k AdminKey
|
||||
if err := rows.Scan(&k.ID, &k.AdminID, &k.CredID, &k.PublicKey, &k.SignCount, &k.Name, &k.CreatedAt, &k.LastUsed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, k)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// PasswordOff tells an admin who turned the password off: then only a
|
||||
// passkey signs them in. It holds only while they have one.
|
||||
func (db *DB) PasswordOff(adminID int64) bool {
|
||||
var off int
|
||||
_ = db.sql.QueryRow(`SELECT password_off FROM admins WHERE id = ?`, adminID).Scan(&off)
|
||||
return off == 1 && db.CountAdminKeys(adminID) > 0
|
||||
}
|
||||
|
||||
// AdminPin is the hash of the admin's PIN, "" when there is none.
|
||||
func (db *DB) AdminPin(adminID int64) string {
|
||||
var h string
|
||||
_ = db.sql.QueryRow(`SELECT pin_hash FROM admins WHERE id = ?`, adminID).Scan(&h)
|
||||
return h
|
||||
}
|
||||
|
||||
func (db *DB) SetAdminPin(adminID int64, pinHash string) error {
|
||||
_, err := db.sql.Exec(`UPDATE admins SET pin_hash = ? WHERE id = ?`, pinHash, adminID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) SetAdminPassword(adminID int64, passHash string) error {
|
||||
_, err := db.sql.Exec(`UPDATE admins SET pass_hash = ? WHERE id = ?`, passHash, adminID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) SetPasswordOff(adminID int64, off bool) error {
|
||||
v := 0
|
||||
if off {
|
||||
v = 1
|
||||
}
|
||||
_, err := db.sql.Exec(`UPDATE admins SET password_off = ? WHERE id = ?`, v, adminID)
|
||||
return err
|
||||
}
|
||||
|
||||
// CountAdminKeys is how many passkeys an admin has.
|
||||
func (db *DB) CountAdminKeys(adminID int64) int {
|
||||
var n int
|
||||
_ = db.sql.QueryRow(`SELECT COUNT(*) FROM admin_keys WHERE admin_id = ?`, adminID).Scan(&n)
|
||||
return n
|
||||
}
|
||||
|
||||
func (db *DB) AdminKeyByCredID(credID []byte) (*AdminKey, error) {
|
||||
k := &AdminKey{}
|
||||
err := db.sql.QueryRow(
|
||||
`SELECT id, admin_id, cred_id, public_key, sign_count, name, created_at, last_used
|
||||
FROM admin_keys WHERE cred_id = ?`, credID).
|
||||
Scan(&k.ID, &k.AdminID, &k.CredID, &k.PublicKey, &k.SignCount, &k.Name, &k.CreatedAt, &k.LastUsed)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return k, err
|
||||
}
|
||||
|
||||
func (db *DB) UseAdminKey(id int64, signCount uint32) error {
|
||||
_, err := db.sql.Exec(`UPDATE admin_keys SET sign_count = ?, last_used = ? WHERE id = ?`, signCount, time.Now().Unix(), id)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteAdminKey removes a passkey; with the last one gone, the password
|
||||
// signs the admin in again.
|
||||
func (db *DB) DeleteAdminKey(adminID, id int64) error {
|
||||
if _, err := db.sql.Exec(`DELETE FROM admin_keys WHERE admin_id = ? AND id = ?`, adminID, id); err != nil {
|
||||
return err
|
||||
}
|
||||
if db.CountAdminKeys(adminID) == 0 {
|
||||
return db.SetPasswordOff(adminID, false)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) DeleteAdminKeys(adminID int64) error {
|
||||
if _, err := db.sql.Exec(`DELETE FROM admin_keys WHERE admin_id = ?`, adminID); err != nil {
|
||||
return err
|
||||
}
|
||||
return db.SetPasswordOff(adminID, false)
|
||||
}
|
||||
|
||||
// --- admin sessions ---
|
||||
|
||||
func (db *DB) CreateSession(id string, adminID int64, ttl time.Duration) error {
|
||||
@@ -200,12 +457,10 @@ func (db *DB) CreateSession(id string, adminID int64, ttl time.Duration) error {
|
||||
|
||||
// SessionAdmin returns the admin for a live (non-expired) session, or nil.
|
||||
func (db *DB) SessionAdmin(sessionID string) (*Admin, error) {
|
||||
a := &Admin{}
|
||||
err := db.sql.QueryRow(
|
||||
`SELECT a.id, a.username, a.pass_hash
|
||||
a, err := scanAdmin(db.sql.QueryRow(
|
||||
`SELECT a.id, a.username, a.pass_hash, a.role, a.pw_locked
|
||||
FROM admin_sessions s JOIN admins a ON a.id = s.admin_id
|
||||
WHERE s.id = ? AND s.expires_at > ?`, sessionID, time.Now().Unix()).
|
||||
Scan(&a.ID, &a.Username, &a.PassHash)
|
||||
WHERE s.id = ? AND s.expires_at > ?`, sessionID, time.Now().Unix()))
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -230,6 +485,7 @@ type Agent struct {
|
||||
Version string
|
||||
RunUser string // OS user the agent process runs as (reported by the agent)
|
||||
LoginUser string // OS user terminals are opened as; "" = RunUser
|
||||
Transport string // auto, ws or lp: how the agent is to reach us
|
||||
LastSeen time.Time
|
||||
}
|
||||
|
||||
@@ -259,8 +515,8 @@ func (db *DB) GetAgent(id string) (*Agent, error) {
|
||||
a := &Agent{}
|
||||
var last int64
|
||||
err := db.sql.QueryRow(
|
||||
`SELECT id, name, hostname, os, arch, token_hash, version, run_user, login_user, last_seen FROM agents WHERE id = ?`, id).
|
||||
Scan(&a.ID, &a.Name, &a.Hostname, &a.OS, &a.Arch, &a.TokenHash, &a.Version, &a.RunUser, &a.LoginUser, &last)
|
||||
`SELECT id, name, hostname, os, arch, token_hash, version, run_user, login_user, transport, last_seen FROM agents WHERE id = ?`, id).
|
||||
Scan(&a.ID, &a.Name, &a.Hostname, &a.OS, &a.Arch, &a.TokenHash, &a.Version, &a.RunUser, &a.LoginUser, &a.Transport, &last)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -291,6 +547,11 @@ func (db *DB) ListAgents() ([]Agent, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (db *DB) SetAgentTransport(id, transport string) error {
|
||||
_, err := db.sql.Exec(`UPDATE agents SET transport = ? WHERE id = ?`, transport, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) TouchAgent(id string) error {
|
||||
_, err := db.sql.Exec(`UPDATE agents SET last_seen = ? WHERE id = ?`, time.Now().Unix(), id)
|
||||
return err
|
||||
@@ -311,6 +572,9 @@ func (db *DB) SetAgentLoginUser(id, user string) error {
|
||||
|
||||
func (db *DB) DeleteAgent(id string) error {
|
||||
_, err := db.sql.Exec(`DELETE FROM agents WHERE id = ?`, id)
|
||||
if err == nil {
|
||||
_, _ = db.sql.Exec(`DELETE FROM admin_agents WHERE agent_id = ?`, id)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -338,14 +602,10 @@ func (db *DB) CreateForwardRule(r ForwardRule) (int64, error) {
|
||||
if r.Proto == "" {
|
||||
r.Proto = "http"
|
||||
}
|
||||
res, err := db.sql.Exec(
|
||||
return db.sql.insert(
|
||||
`INSERT INTO forward_rules(agent_id, client_port, host_port, listen_addr, proto, no_ufw, label, created_at)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
r.AgentID, r.ClientPort, r.HostPort, r.ListenAddr, r.Proto, r.NoUfw, r.Label, time.Now().Unix())
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
r.AgentID, r.ClientPort, r.HostPort, r.ListenAddr, r.Proto, boolInt(r.NoUfw), r.Label, time.Now().Unix())
|
||||
}
|
||||
|
||||
func (db *DB) ListForwardRules() ([]ForwardRule, error) {
|
||||
@@ -487,3 +747,10 @@ func (db *DB) Audit(adminID int64, agentID, action string) {
|
||||
`INSERT INTO audit(admin_id, agent_id, action, at) VALUES(?, ?, ?, ?)`,
|
||||
adminID, agentID, action, time.Now().Unix())
|
||||
}
|
||||
|
||||
func boolInt(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
)
|
||||
|
||||
// The panel keeps its data in SQLite by default, or in PostgreSQL when the
|
||||
// admins choose so in Настройки. The choice lives in a small file beside the
|
||||
// SQLite database (<db>.backend.json, readable by the server only) and
|
||||
// takes effect on the next start; the first start on an empty PostgreSQL
|
||||
// copies everything over from the SQLite file, which stays as it was.
|
||||
|
||||
type PGConf struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Name string `json:"name"`
|
||||
User string `json:"user"`
|
||||
Password string `json:"password"`
|
||||
SSLMode string `json:"sslmode"`
|
||||
}
|
||||
|
||||
type backendConf struct {
|
||||
Type string `json:"type"` // sqlite or postgres
|
||||
PG *PGConf `json:"pg,omitempty"`
|
||||
}
|
||||
|
||||
func (c PGConf) DSN() string {
|
||||
port := c.Port
|
||||
if port == 0 {
|
||||
port = 5432
|
||||
}
|
||||
ssl := c.SSLMode
|
||||
if ssl == "" {
|
||||
ssl = "prefer"
|
||||
}
|
||||
u := url.URL{Scheme: "postgres", User: url.UserPassword(c.User, c.Password), Host: c.Host + ":" + strconv.Itoa(port), Path: "/" + c.Name}
|
||||
u.RawQuery = url.Values{"sslmode": {ssl}, "connect_timeout": {"10"}}.Encode()
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func backendPath(dbPath string) string { return dbPath + ".backend.json" }
|
||||
|
||||
func readBackend(dbPath string) backendConf {
|
||||
var c backendConf
|
||||
if b, err := os.ReadFile(backendPath(dbPath)); err == nil {
|
||||
_ = json.Unmarshal(b, &c)
|
||||
}
|
||||
if c.Type != "postgres" || c.PG == nil {
|
||||
c = backendConf{Type: "sqlite"}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func writeBackend(dbPath string, c backendConf) error {
|
||||
b, _ := json.MarshalIndent(c, "", " ")
|
||||
return os.WriteFile(backendPath(dbPath), b, 0o600)
|
||||
}
|
||||
|
||||
// OpenStore opens the database the admins chose: SQLite at path, or the
|
||||
// PostgreSQL named beside it.
|
||||
func OpenStore(path string) (*DB, error) {
|
||||
c := readBackend(path)
|
||||
if c.Type == "postgres" {
|
||||
return OpenPostgres(*c.PG, path)
|
||||
}
|
||||
return OpenDB(path)
|
||||
}
|
||||
|
||||
// pingPG checks that PostgreSQL c answers.
|
||||
func pingPG(c PGConf) error {
|
||||
d, err := sql.Open("pgx", c.DSN())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer d.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
return d.PingContext(ctx)
|
||||
}
|
||||
|
||||
// OpenPostgres opens PostgreSQL c, brings its tables up to date and, when it
|
||||
// has no users yet, copies over the SQLite database at sqlitePath.
|
||||
func OpenPostgres(c PGConf, sqlitePath string) (*DB, error) {
|
||||
d, err := sql.Open("pgx", c.DSN())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.SetMaxOpenConns(16)
|
||||
d.SetMaxIdleConns(4)
|
||||
d.SetConnMaxIdleTime(5 * time.Minute)
|
||||
if err := d.Ping(); err != nil {
|
||||
return nil, fmt.Errorf("PostgreSQL: %w", err)
|
||||
}
|
||||
db := &DB{sql: &dbx{DB: d, pg: true}, path: sqlitePath}
|
||||
if err := db.migrate(); err != nil {
|
||||
return nil, fmt.Errorf("PostgreSQL: %w", err)
|
||||
}
|
||||
var n int
|
||||
_ = db.sql.QueryRow(`SELECT COUNT(*) FROM admins`).Scan(&n)
|
||||
if _, err := os.Stat(sqlitePath); n == 0 && err == nil {
|
||||
src, err := OpenDB(sqlitePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer src.Close()
|
||||
if err := copyDB(src, db); err != nil {
|
||||
return nil, fmt.Errorf("перенос из SQLite: %w", err)
|
||||
}
|
||||
log.Printf("db: data copied from %s to PostgreSQL", sqlitePath)
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// copyTables are copied parents first, for the foreign keys.
|
||||
var copyTables = []string{"config", "admins", "agents", "admin_sessions", "audit", "ufw_rules", "admin_keys",
|
||||
"forward_rules", "admin_agents", "ssh_hosts", "ai_chats", "ai_messages"}
|
||||
|
||||
// copyDB copies every row of src into dst (an empty PostgreSQL), in one
|
||||
// transaction, then moves the id counters past the copied ids.
|
||||
func copyDB(src, dst *DB) error {
|
||||
tx, err := dst.sql.DB.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
for _, t := range copyTables {
|
||||
rows, err := src.sql.Query(`SELECT * FROM ` + t)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cols, _ := rows.Columns()
|
||||
ph := make([]string, len(cols))
|
||||
for i := range ph {
|
||||
ph[i] = "$" + strconv.Itoa(i+1)
|
||||
}
|
||||
ins := `INSERT INTO ` + t + ` (` + strings.Join(cols, ", ") + `) VALUES (` + strings.Join(ph, ", ") + `)`
|
||||
for rows.Next() {
|
||||
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 {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ins, vals...); err != nil {
|
||||
rows.Close()
|
||||
return fmt.Errorf("%s: %w", t, err)
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
// The id counters past the copied ids, where a table has one.
|
||||
for _, t := range copyTables {
|
||||
var seq sql.NullString
|
||||
err := dst.sql.DB.QueryRow(`SELECT pg_get_serial_sequence($1, column_name) FROM information_schema.columns
|
||||
WHERE table_name = $1 AND column_name = 'id' AND column_default LIKE 'nextval%'`, t).Scan(&seq)
|
||||
if err != nil || !seq.Valid {
|
||||
continue
|
||||
}
|
||||
if _, err := dst.sql.DB.Exec(`SELECT setval($1, COALESCE((SELECT MAX(id) FROM `+t+`), 0) + 1, false)`, seq.String); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Backend tells which database is in use and which is chosen for the next
|
||||
// start.
|
||||
func (db *DB) Backend() (now string, chosen backendConf) {
|
||||
now = "sqlite"
|
||||
if db.sql.pg {
|
||||
now = "postgres"
|
||||
}
|
||||
return now, readBackend(db.path)
|
||||
}
|
||||
|
||||
var errNoPGHost = errors.New("укажите хост, базу и пользователя PostgreSQL")
|
||||
|
||||
// POST /settings/db - the database for the next start; PostgreSQL is tried
|
||||
// first and kept only if it answers.
|
||||
func (s *Server) handleSetDB(w http.ResponseWriter, r *http.Request) {
|
||||
if !checkCSRF(r) {
|
||||
http.Error(w, "bad csrf", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
back := func(q string) { http.Redirect(w, r, "/settings?"+q+"#db", http.StatusSeeOther) }
|
||||
if r.FormValue("db_type") != "postgres" {
|
||||
if err := writeBackend(s.db.path, backendConf{Type: "sqlite"}); err != nil {
|
||||
back("err=" + url.QueryEscape(err.Error()))
|
||||
return
|
||||
}
|
||||
back("db=saved")
|
||||
return
|
||||
}
|
||||
old := readBackend(s.db.path).PG
|
||||
c := PGConf{Host: strings.TrimSpace(r.FormValue("pg_host")), Name: strings.TrimSpace(r.FormValue("pg_name")),
|
||||
User: strings.TrimSpace(r.FormValue("pg_user")), Password: r.FormValue("pg_password"), SSLMode: r.FormValue("pg_sslmode")}
|
||||
c.Port, _ = strconv.Atoi(r.FormValue("pg_port"))
|
||||
if c.Password == "" && old != nil {
|
||||
c.Password = old.Password
|
||||
}
|
||||
if c.Host == "" || c.Name == "" || c.User == "" {
|
||||
back("err=" + url.QueryEscape(errNoPGHost.Error()))
|
||||
return
|
||||
}
|
||||
if err := pingPG(c); err != nil {
|
||||
back("err=" + url.QueryEscape("PostgreSQL не отвечает: "+err.Error()))
|
||||
return
|
||||
}
|
||||
if err := writeBackend(s.db.path, backendConf{Type: "postgres", PG: &c}); err != nil {
|
||||
back("err=" + url.QueryEscape(err.Error()))
|
||||
return
|
||||
}
|
||||
back("db=saved")
|
||||
}
|
||||
|
||||
// POST /settings/db/restart - the server ends; its service (systemd,
|
||||
// Restart=always) starts it again on the database chosen.
|
||||
func (s *Server) handleRestart(w http.ResponseWriter, r *http.Request) {
|
||||
if !checkCSRF(r) {
|
||||
http.Error(w, "bad csrf", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
log.Printf("restart asked from the panel")
|
||||
http.Redirect(w, r, "/settings?restarted=1#db", http.StatusSeeOther)
|
||||
go func() {
|
||||
time.Sleep(700 * time.Millisecond)
|
||||
os.Exit(0)
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||
)
|
||||
|
||||
// On a real PostgreSQL (LPRA_TEST_PG: {"host","port","name","user","password",
|
||||
// "sslmode"} of an empty database): the first start copies the SQLite data
|
||||
// over, and the panel's queries work there.
|
||||
func TestPostgres(t *testing.T) {
|
||||
raw := os.Getenv("LPRA_TEST_PG")
|
||||
if raw == "" {
|
||||
t.Skip("LPRA_TEST_PG not set")
|
||||
}
|
||||
var c PGConf
|
||||
if err := json.Unmarshal([]byte(raw), &c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// SQLite with a user, a machine, a rule, a saved host and some talk.
|
||||
s, _ := newTestServer(t)
|
||||
a, _ := s.db.AdminByUsername("adm")
|
||||
if _, err := s.db.CreateForwardRule(ForwardRule{AgentID: "x1", ClientPort: 80, HostPort: 18080, ListenAddr: "0.0.0.0", Proto: "tcp", NoUfw: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.saveSSHHost(a.ID, savedHost{Label: "web", Host: "h", Port: 22, User: "root", Auth: "password"}, sshSecret{Password: "pw"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
chat := s.aiNewChat(a.ID)
|
||||
s.aiAppendTo(a.ID, chat, "user", "привет")
|
||||
_ = s.db.SetUserAgents(a.ID, []string{"x1"})
|
||||
|
||||
pgReset(t, c)
|
||||
pg, err := OpenPostgres(c, s.db.path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pg.Close()
|
||||
// Clean up for the next run.
|
||||
defer func() {
|
||||
for i := len(copyTables) - 1; i >= 0; i-- {
|
||||
_, _ = pg.sql.Exec(`DROP TABLE IF EXISTS ` + copyTables[i] + ` CASCADE`)
|
||||
}
|
||||
}()
|
||||
p := &Server{db: pg}
|
||||
if got, _ := pg.AdminByUsername("adm"); got == nil || got.ID != a.ID {
|
||||
t.Fatalf("admin not copied: %+v", got)
|
||||
}
|
||||
if ag, _ := pg.GetAgent("x1"); ag == nil {
|
||||
t.Fatal("agent not copied")
|
||||
}
|
||||
rules, _ := pg.ListForwardRules()
|
||||
if len(rules) != 1 || !rules[0].NoUfw {
|
||||
t.Fatalf("rules: %+v", rules)
|
||||
}
|
||||
tg := &proto.SSHTarget{Saved: p.listSSHHosts(a.ID)[0].ID}
|
||||
if err := p.fillSaved(a.ID, tg); err != nil || tg.Password != "pw" {
|
||||
t.Fatalf("saved host: %+v %v", tg, err)
|
||||
}
|
||||
if h := p.aiHistoryOf(a.ID, chat, 10); len(h) != 1 || h[0].Content != "привет" {
|
||||
t.Fatalf("talk: %+v", h)
|
||||
}
|
||||
// New rows go on after the copied ids.
|
||||
id, err := pg.CreateUser("bob", "x", RoleUser)
|
||||
if err != nil || id <= a.ID {
|
||||
t.Fatalf("new user id %d, %v", id, err)
|
||||
}
|
||||
if err := pg.SetConfig("k", "v1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pg.SetConfig("k", "v2"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v, _ := pg.GetConfig("k"); v != "v2" {
|
||||
t.Fatalf("config upsert: %q", v)
|
||||
}
|
||||
if err := pg.SetUserAgents(id, []string{"x1", "x1"}); err != nil {
|
||||
t.Fatalf("grants: %v", err)
|
||||
}
|
||||
p.aiAppendTo(a.ID, chat, "assistant", "ответ")
|
||||
if h := p.aiHistoryOf(a.ID, chat, 10); len(h) != 2 {
|
||||
t.Fatalf("talk after: %+v", h)
|
||||
}
|
||||
}
|
||||
|
||||
// Chats on PostgreSQL: past the limit the oldest go.
|
||||
func TestPostgresChats(t *testing.T) {
|
||||
raw := os.Getenv("LPRA_TEST_PG")
|
||||
if raw == "" {
|
||||
t.Skip("LPRA_TEST_PG not set")
|
||||
}
|
||||
var c PGConf
|
||||
_ = json.Unmarshal([]byte(raw), &c)
|
||||
s, _ := newTestServer(t)
|
||||
pgReset(t, c)
|
||||
pg, err := OpenPostgres(c, s.db.path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pg.Close()
|
||||
defer func() {
|
||||
for i := len(copyTables) - 1; i >= 0; i-- {
|
||||
_, _ = pg.sql.Exec(`DROP TABLE IF EXISTS ` + copyTables[i] + ` CASCADE`)
|
||||
}
|
||||
}()
|
||||
p := &Server{db: pg}
|
||||
a, _ := pg.AdminByUsername("adm")
|
||||
for i := 0; i < aiChatsKeep+3; i++ {
|
||||
p.aiAppendTo(a.ID, p.aiNewChat(a.ID), "user", "q")
|
||||
}
|
||||
if n := len(p.aiChats(a.ID)); n != aiChatsKeep {
|
||||
t.Fatalf("chats kept: %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// pgReset empties the test database: the copy runs only into an empty one.
|
||||
func pgReset(t *testing.T, c PGConf) {
|
||||
d, err := sql.Open("pgx", c.DSN())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer d.Close()
|
||||
for i := len(copyTables) - 1; i >= 0; i-- {
|
||||
_, _ = d.Exec(`DROP TABLE IF EXISTS ` + copyTables[i] + ` CASCADE`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// dbx is the database the server talks to: SQLite, or PostgreSQL. The
|
||||
// queries are written once, for SQLite; on PostgreSQL each is rewritten
|
||||
// (pgSQL) on its way.
|
||||
type dbx struct {
|
||||
*sql.DB
|
||||
pg bool
|
||||
}
|
||||
|
||||
func (d *dbx) q(query string) string {
|
||||
if d.pg {
|
||||
return pgSQL(query)
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
func (d *dbx) Exec(query string, args ...any) (sql.Result, error) {
|
||||
return d.DB.Exec(d.q(query), args...)
|
||||
}
|
||||
func (d *dbx) Query(query string, args ...any) (*sql.Rows, error) {
|
||||
return d.DB.Query(d.q(query), args...)
|
||||
}
|
||||
func (d *dbx) QueryRow(query string, args ...any) *sql.Row {
|
||||
return d.DB.QueryRow(d.q(query), args...)
|
||||
}
|
||||
func (d *dbx) Begin() (*txx, error) {
|
||||
tx, err := d.DB.Begin()
|
||||
return &txx{Tx: tx, pg: d.pg}, err
|
||||
}
|
||||
|
||||
// insert runs an INSERT and gives the new row's id (LastInsertId on SQLite,
|
||||
// RETURNING id on PostgreSQL, which has no LastInsertId).
|
||||
func (d *dbx) insert(query string, args ...any) (int64, error) {
|
||||
if d.pg {
|
||||
var id int64
|
||||
err := d.DB.QueryRow(pgSQL(query)+" RETURNING id", args...).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
res, err := d.DB.Exec(query, args...)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
type txx struct {
|
||||
*sql.Tx
|
||||
pg bool
|
||||
}
|
||||
|
||||
func (t *txx) Exec(query string, args ...any) (sql.Result, error) {
|
||||
if t.pg {
|
||||
query = pgSQL(query)
|
||||
}
|
||||
return t.Tx.Exec(query, args...)
|
||||
}
|
||||
|
||||
// pgSQL rewrites a query for SQLite into PostgreSQL's: $n placeholders,
|
||||
// INSERT OR IGNORE as ON CONFLICT DO NOTHING, and in table definitions
|
||||
// BIGSERIAL keys, BIGINT numbers (times are Unix seconds), BYTEA blobs and
|
||||
// ADD COLUMN IF NOT EXISTS.
|
||||
func pgSQL(q string) string {
|
||||
if strings.HasPrefix(q, "INSERT OR IGNORE ") {
|
||||
q = "INSERT " + strings.TrimPrefix(q, "INSERT OR IGNORE ") + " ON CONFLICT DO NOTHING"
|
||||
}
|
||||
if strings.Contains(q, "CREATE TABLE") || strings.Contains(q, "ALTER TABLE") {
|
||||
q = strings.ReplaceAll(q, "INTEGER PRIMARY KEY AUTOINCREMENT", "BIGSERIAL PRIMARY KEY")
|
||||
q = strings.ReplaceAll(q, "INTEGER", "BIGINT")
|
||||
q = strings.ReplaceAll(q, "BLOB", "BYTEA")
|
||||
q = strings.ReplaceAll(q, "ADD COLUMN ", "ADD COLUMN IF NOT EXISTS ")
|
||||
}
|
||||
var b strings.Builder
|
||||
n, quoted := 0, false
|
||||
for _, r := range q {
|
||||
switch {
|
||||
case r == '\'':
|
||||
quoted = !quoted
|
||||
case r == '?' && !quoted:
|
||||
n++
|
||||
b.WriteString("$" + strconv.Itoa(n))
|
||||
continue
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package server
|
||||
|
||||
import "testing"
|
||||
|
||||
// Queries written for SQLite, as PostgreSQL takes them.
|
||||
func TestPGRebind(t *testing.T) {
|
||||
for _, c := range [][2]string{
|
||||
{`SELECT a FROM t WHERE x = ? AND y = ?`, `SELECT a FROM t WHERE x = $1 AND y = $2`},
|
||||
{`INSERT OR IGNORE INTO admin_agents(admin_id, agent_id) VALUES(?, ?)`, `INSERT INTO admin_agents(admin_id, agent_id) VALUES($1, $2) ON CONFLICT DO NOTHING`},
|
||||
{`SELECT '?' , ?`, `SELECT '?' , $1`},
|
||||
{"CREATE TABLE IF NOT EXISTS x (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n n INTEGER NOT NULL,\n b BLOB NOT NULL)",
|
||||
"CREATE TABLE IF NOT EXISTS x (\n id BIGSERIAL PRIMARY KEY,\n n BIGINT NOT NULL,\n b BYTEA NOT NULL)"},
|
||||
{`ALTER TABLE agents ADD COLUMN run_user TEXT NOT NULL DEFAULT ''`, `ALTER TABLE agents ADD COLUMN IF NOT EXISTS run_user TEXT NOT NULL DEFAULT ''`},
|
||||
} {
|
||||
if got := pgSQL(c[0]); got != c[1] {
|
||||
t.Errorf("\n got %s\nwant %s", got, c[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"html"
|
||||
"io"
|
||||
"mime"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// fetch: one page by its address, for the assistant with «Поиск» on - to
|
||||
// read what a search found. The page comes back as the model wants it: its
|
||||
// title and main text, headings and lists kept, scripts, styles and menus
|
||||
// gone, cut to fetchMaxChars. Only http(s), and never the server's own
|
||||
// network: loopback, private and link-local addresses are refused at the
|
||||
// moment of connecting, redirects included.
|
||||
|
||||
const fetchMaxChars = 12000
|
||||
|
||||
// fetchAllowPrivate lets tests reach their own server.
|
||||
var fetchAllowPrivate = false
|
||||
|
||||
type fetchedPage struct {
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title"`
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
Truncated bool `json:"truncated"`
|
||||
}
|
||||
|
||||
func privateIP(ip net.IP) bool {
|
||||
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
|
||||
ip.IsUnspecified() || ip.IsMulticast() || ip.IsInterfaceLocalMulticast()
|
||||
}
|
||||
|
||||
var fetchClient = &http.Client{
|
||||
Timeout: 20 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
Proxy: nil,
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, ip := range ips {
|
||||
if privateIP(ip) && !fetchAllowPrivate {
|
||||
continue
|
||||
}
|
||||
var d net.Dialer
|
||||
return d.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
|
||||
}
|
||||
return nil, errors.New("адрес во внутренней сети - не открываю")
|
||||
},
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ResponseHeaderTimeout: 15 * time.Second,
|
||||
},
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 5 {
|
||||
return errors.New("слишком много переадресаций")
|
||||
}
|
||||
if req.URL.Scheme != "http" && req.URL.Scheme != "https" {
|
||||
return errors.New("переадресация не на http(s)")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func fetchPage(ctx context.Context, raw string) (fetchedPage, error) {
|
||||
u, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||
return fetchedPage{}, errors.New("нужен адрес http(s)://…")
|
||||
}
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36")
|
||||
req.Header.Set("Accept", "text/html,text/plain,application/json;q=0.9,*/*;q=0.5")
|
||||
req.Header.Set("Accept-Language", "ru,en;q=0.8")
|
||||
resp, err := fetchClient.Do(req)
|
||||
if err != nil {
|
||||
return fetchedPage{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return fetchedPage{}, errors.New("страница ответила " + resp.Status)
|
||||
}
|
||||
ct, _, _ := mime.ParseMediaType(resp.Header.Get("Content-Type"))
|
||||
p := fetchedPage{URL: resp.Request.URL.String(), Type: ct}
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 3<<20))
|
||||
switch {
|
||||
case ct == "text/html" || ct == "application/xhtml+xml" || (ct == "" && strings.Contains(strings.ToLower(string(b[:min(len(b), 512)])), "<html")):
|
||||
p.Title, p.Text = pageText(string(b))
|
||||
case strings.HasPrefix(ct, "text/") || strings.Contains(ct, "json") || strings.Contains(ct, "xml") || strings.Contains(ct, "javascript"):
|
||||
p.Text = strings.TrimSpace(string(b))
|
||||
default:
|
||||
p.Text = "(двоичный файл " + ct + ", " + strconv.Itoa(len(b)) + " байт - текста нет)"
|
||||
}
|
||||
if !utf8.ValidString(p.Text) {
|
||||
p.Text = strings.ToValidUTF8(p.Text, "")
|
||||
}
|
||||
if r := []rune(p.Text); len(r) > fetchMaxChars {
|
||||
p.Text, p.Truncated = string(r[:fetchMaxChars]), true
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
var (
|
||||
reTitle = regexp.MustCompile(`(?is)<title[^>]*>(.*?)</title>`)
|
||||
reDrop = regexp.MustCompile(`(?is)<(script|style|noscript|svg|nav|footer|header|aside|form|iframe|template|button|select)\b[^>]*>.*?</(script|style|noscript|svg|nav|footer|header|aside|form|iframe|template|button|select)>`)
|
||||
reComment = regexp.MustCompile(`(?s)<!--.*?-->`)
|
||||
reMain = regexp.MustCompile(`(?is)<(main|article)\b[^>]*>(.*)</(main|article)>`)
|
||||
reHead = regexp.MustCompile(`(?is)<h([1-6])\b[^>]*>`)
|
||||
reLi = regexp.MustCompile(`(?is)<li\b[^>]*>`)
|
||||
reBlock = regexp.MustCompile(`(?is)</?(p|div|section|br|tr|table|ul|ol|pre|blockquote|h[1-6]|dd|dt|figcaption)\b[^>]*>`)
|
||||
reCell = regexp.MustCompile(`(?is)</t[dh]>`)
|
||||
reSpaces = regexp.MustCompile(`[ \t\x{00a0}]+`)
|
||||
reBlanks = regexp.MustCompile(`\n{3,}`)
|
||||
rePre = regexp.MustCompile(`(?is)<pre\b[^>]*>.*?</pre>`)
|
||||
rePreMark = regexp.MustCompile("\x00pre[0-9]+\x00")
|
||||
reWS = regexp.MustCompile(`\s+`)
|
||||
)
|
||||
|
||||
// pageText is a page's title and its text, in Markdown-ish lines.
|
||||
func pageText(page string) (string, string) {
|
||||
title := ""
|
||||
if m := reTitle.FindStringSubmatch(page); m != nil {
|
||||
title = strings.TrimSpace(html.UnescapeString(reTags.ReplaceAllString(m[1], "")))
|
||||
}
|
||||
s := reComment.ReplaceAllString(page, "")
|
||||
s = reDrop.ReplaceAllString(s, " ")
|
||||
// Line breaks of the source mean nothing outside <pre>: one space. The
|
||||
// <pre> blocks keep theirs (held aside meanwhile).
|
||||
var pres []string
|
||||
s = rePre.ReplaceAllStringFunc(s, func(p string) string {
|
||||
pres = append(pres, p)
|
||||
return "\x00pre" + strconv.Itoa(len(pres)-1) + "\x00"
|
||||
})
|
||||
s = reWS.ReplaceAllString(s, " ")
|
||||
s = rePreMark.ReplaceAllStringFunc(s, func(m string) string {
|
||||
i, _ := strconv.Atoi(strings.Trim(m, "\x00pre"))
|
||||
return pres[i]
|
||||
})
|
||||
// The page's main part, when it marks one.
|
||||
if m := reMain.FindStringSubmatch(s); m != nil && len(m[2]) > 200 {
|
||||
s = m[2]
|
||||
}
|
||||
s = reHead.ReplaceAllStringFunc(s, func(t string) string {
|
||||
n, _ := strconv.Atoi(reHead.FindStringSubmatch(t)[1])
|
||||
return "\n\n" + strings.Repeat("#", n) + " "
|
||||
})
|
||||
s = reLi.ReplaceAllString(s, "\n- ")
|
||||
s = reCell.ReplaceAllString(s, " | ")
|
||||
s = reBlock.ReplaceAllString(s, "\n")
|
||||
s = html.UnescapeString(reTags.ReplaceAllString(s, ""))
|
||||
var lines []string
|
||||
for _, l := range strings.Split(s, "\n") {
|
||||
l = strings.TrimSpace(reSpaces.ReplaceAllString(l, " "))
|
||||
if l == "-" || l == "|" {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, l)
|
||||
}
|
||||
text := reBlanks.ReplaceAllString(strings.Join(lines, "\n"), "\n\n")
|
||||
return title, strings.TrimSpace(text)
|
||||
}
|
||||
|
||||
// POST /api/panel/ai/fetch - {url}: the page as text, for a user with the
|
||||
// assistant on.
|
||||
func (s *Server) handleAIFetch(w http.ResponseWriter, r *http.Request) {
|
||||
a := s.currentAdmin(r)
|
||||
if on, _ := s.db.GetConfig(aiUserKey(a.ID)); on != "1" {
|
||||
writeJSON(w, map[string]any{"ok": false, "error": "ассистент выключен в профиле"})
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10)).Decode(&in); err != nil {
|
||||
writeJSON(w, map[string]any{"ok": false, "error": "плохой запрос"})
|
||||
return
|
||||
}
|
||||
p, err := fetchPage(r.Context(), in.URL)
|
||||
if err != nil {
|
||||
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"ok": true, "data": p})
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A page as the model wants it: title, the main text with headings and
|
||||
// lists, no scripts, styles or menus.
|
||||
func TestPageText(t *testing.T) {
|
||||
page := `<html><head><title>Go 1.24 & more</title><style>p{color:red}</style>
|
||||
<meta name="description" content="Release notes"></head><body>
|
||||
<nav><a href="/">Home</a> <a href="/doc">Docs</a></nav>
|
||||
<main><h1>Go 1.24</h1><p>Generic <b>type aliases</b> are here.</p><script>track()</script>
|
||||
<ul><li>Swiss tables</li><li>weak pointers</li></ul></main><footer>© Google</footer></body></html>`
|
||||
title, text := pageText(page)
|
||||
if title != "Go 1.24 & more" {
|
||||
t.Fatalf("title %q", title)
|
||||
}
|
||||
for _, want := range []string{"# Go 1.24", "Generic type aliases are here.", "- Swiss tables", "- weak pointers"} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("no %q in:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
for _, not := range []string{"track()", "color:red", "Home", "© Google"} {
|
||||
if strings.Contains(text, not) {
|
||||
t.Fatalf("%q left in:\n%s", not, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The server's own network is out of reach: no loopback, private or local
|
||||
// addresses, whatever the name says.
|
||||
func TestFetchRefusesPrivate(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "secret") }))
|
||||
defer srv.Close()
|
||||
for _, u := range []string{srv.URL, "http://localhost:1/", "http://10.0.0.1/", "http://169.254.169.254/latest/meta-data/", "file:///etc/passwd", "ftp://x/"} {
|
||||
if _, err := fetchPage(context.Background(), u); err == nil {
|
||||
t.Errorf("%s: fetched", u)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchPage(t *testing.T) {
|
||||
old := fetchAllowPrivate
|
||||
fetchAllowPrivate = true
|
||||
defer func() { fetchAllowPrivate = old }()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/json" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"a":1}`)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprint(w, "<title>T</title><p>"+strings.Repeat("слово ", 5000)+"</p>")
|
||||
}))
|
||||
defer srv.Close()
|
||||
p, err := fetchPage(context.Background(), srv.URL+"/page")
|
||||
if err != nil || p.Title != "T" || !p.Truncated || len([]rune(p.Text)) > fetchMaxChars+10 {
|
||||
t.Fatalf("page: %v %q %v %d", err, p.Title, p.Truncated, len(p.Text))
|
||||
}
|
||||
p, err = fetchPage(context.Background(), srv.URL+"/json")
|
||||
if err != nil || p.Text != `{"a":1}` {
|
||||
t.Fatalf("json: %+v %v", p, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Lines broken in the page's source are one paragraph; code keeps its lines.
|
||||
func TestPageTextJoinsWrapped(t *testing.T) {
|
||||
_, text := pageText("<p>The latest Go release,\n version 1.24,\narrives in February.</p><pre>a\nb</pre>")
|
||||
if !strings.Contains(text, "The latest Go release, version 1.24, arrives in February.") || !strings.Contains(text, "a\nb") {
|
||||
t.Fatalf("%q", text)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||
)
|
||||
|
||||
// A panel page is either a fragment - its content alone, for the shell to put
|
||||
// in place without reloading - or the shell with that content already in it.
|
||||
|
||||
func isFrag(r *http.Request) bool { return r.URL.Query().Get("frag") == "1" }
|
||||
|
||||
// pageView is the shell's view a page is shown in; the machine pages (Порты,
|
||||
// Статус, Изменить) open as a dialog over the machines view.
|
||||
var pageView = map[string]string{"dashboard": "dashboard", "settings": "settings"}
|
||||
|
||||
func (s *Server) renderPage(w http.ResponseWriter, r *http.Request, name string, data map[string]any) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if data == nil {
|
||||
data = map[string]any{}
|
||||
}
|
||||
data["IsAdmin"] = s.currentAdmin(r).IsAdmin()
|
||||
if !isFrag(r) {
|
||||
if _, ok := pageView[name]; !ok {
|
||||
// A machine page asked for whole: the machines view with it in
|
||||
// a dialog.
|
||||
s.shell(w, r, map[string]any{"View": "dashboard", "Dialog": r.URL.Path})
|
||||
return
|
||||
}
|
||||
}
|
||||
var body bytes.Buffer
|
||||
if err := s.tmpl.ExecuteTemplate(&body, name+"-body", data); err != nil {
|
||||
log.Printf("template %s: %v", name, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if isFrag(r) {
|
||||
_, _ = w.Write(body.Bytes())
|
||||
return
|
||||
}
|
||||
data["View"] = pageView[name]
|
||||
data["Body"] = template.HTML(body.String())
|
||||
s.shell(w, r, data)
|
||||
}
|
||||
|
||||
// shell writes the shell page with data (View, Body, Open, Dialog).
|
||||
func (s *Server) shell(w http.ResponseWriter, r *http.Request, data map[string]any) {
|
||||
data["IsAdmin"] = s.currentAdmin(r).IsAdmin()
|
||||
data["EditLimit"] = proto.FSReadCap
|
||||
if a := s.currentAdmin(r); a != nil {
|
||||
data["Prefs"] = s.prefs(a.ID)
|
||||
}
|
||||
if data["View"] == "dashboard" && data["Body"] == nil {
|
||||
// The machines list under a dialog: draw it too.
|
||||
var body bytes.Buffer
|
||||
if err := s.tmpl.ExecuteTemplate(&body, "dashboard-body", s.dashboardData(r)); err == nil {
|
||||
data["Body"] = template.HTML(body.String())
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if err := s.tmpl.ExecuteTemplate(w, "app.html", data); err != nil {
|
||||
log.Printf("template app.html: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// handleShell serves the shell alone: the terminal view, or with a pane of
|
||||
// a machine opened at once (/agents/{id}/terminal, /agents/{id}/files).
|
||||
func (s *Server) handleShell(w http.ResponseWriter, r *http.Request) {
|
||||
data := map[string]any{"View": "terminal"}
|
||||
if id := r.PathValue("id"); id != "" {
|
||||
a, _ := s.db.GetAgent(id)
|
||||
if a == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
kind := "term"
|
||||
switch r.PathValue("kind") {
|
||||
case "", "terminal":
|
||||
case "files":
|
||||
kind = "files"
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
data["Open"] = map[string]any{"kind": kind, "agent": map[string]string{"id": a.ID, "name": a.Name, "host": a.Hostname, "user": a.TerminalUser()}}
|
||||
}
|
||||
s.shell(w, r, data)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// newTestServer is a server on a fresh database with an admin signed in (the
|
||||
// cookie) and one machine, x1.
|
||||
func newTestServer(t *testing.T) (*Server, *http.Cookie) {
|
||||
t.Helper()
|
||||
db, err := OpenDB(t.TempDir() + "/t.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, err := New(Options{DB: db, DistDir: t.TempDir()})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, _ := hashPassword("Passw0rd12345")
|
||||
if err := db.CreateAdmin("adm", hash); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = db.SetConfig(cfgSetupDone, "1")
|
||||
a, _ := db.AdminByUsername("adm")
|
||||
if err := db.CreateSession("sid-test", a.ID, sessionTTL); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.CreateAgent(Agent{ID: "x1", Name: "t", Hostname: "h", OS: "linux", Arch: "amd64", TokenHash: "x"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s, &http.Cookie{Name: sessionCookie, Value: "sid-test"}
|
||||
}
|
||||
|
||||
func get(t *testing.T, s *Server, c *http.Cookie, path string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
req.AddCookie(c)
|
||||
w := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
func TestFragmentHasNoChrome(t *testing.T) {
|
||||
s, cookie := newTestServer(t)
|
||||
w := get(t, s, cookie, "/settings?frag=1")
|
||||
body := w.Body.String()
|
||||
if w.Code != 200 || strings.Contains(body, "<html") || strings.Contains(body, "topbar") {
|
||||
t.Fatalf("fragment: code %d, has chrome: %q", w.Code, body[:min(200, len(body))])
|
||||
}
|
||||
if !strings.Contains(body, `data-module="settings"`) {
|
||||
t.Fatalf("fragment has no module root")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellRoutes(t *testing.T) {
|
||||
s, cookie := newTestServer(t)
|
||||
for _, p := range []string{"/", "/workspace", "/terminal", "/settings", "/agents/x1/files", "/agents/x1/terminal"} {
|
||||
if w := get(t, s, cookie, p); !strings.Contains(w.Body.String(), `id="app"`) {
|
||||
t.Errorf("%s: not the shell (code %d)", p, w.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHFSOpen(t *testing.T) {
|
||||
s, admin := newTestServer(t)
|
||||
// Long gone: the machine is offline.
|
||||
if _, err := s.db.sql.Exec(`UPDATE agents SET last_seen = 0`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
post := func(c *http.Cookie) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/panel/agents/x1/sshfs/open", strings.NewReader(`{"ssh":{"host":"h","user":"u","password":"p"}}`))
|
||||
req.AddCookie(c)
|
||||
w := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
// The machine exists but is offline: the route answers in JSON.
|
||||
if w := post(admin); w.Code != 200 || !strings.Contains(w.Body.String(), "офлайн") {
|
||||
t.Fatalf("admin: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
// A user without the machine does not reach it.
|
||||
hash, _ := hashPassword("userpass123")
|
||||
id, _ := s.db.CreateUser("bob", hash, RoleUser)
|
||||
_ = s.db.CreateSession("sid-bob", id, sessionTTL)
|
||||
if w := post(&http.Cookie{Name: sessionCookie, Value: "sid-bob"}); w.Code != http.StatusNotFound {
|
||||
t.Fatalf("a user without the machine got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Renaming from the machine dialog keeps it open on «Изменить».
|
||||
func TestRenameStaysOnEdit(t *testing.T) {
|
||||
s, c := newTestServer(t)
|
||||
tok := "tok-test"
|
||||
var req *http.Request
|
||||
req = httptest.NewRequest(http.MethodPost, "/agents/x1/rename", strings.NewReader("csrf="+tok+"&name=new"))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(c)
|
||||
req.AddCookie(&http.Cookie{Name: csrfCookie, Value: tok})
|
||||
w := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(w, req)
|
||||
if loc := w.Header().Get("Location"); w.Code != http.StatusSeeOther || loc != "/agents/x1/edit" {
|
||||
t.Fatalf("rename: %d to %q", w.Code, loc)
|
||||
}
|
||||
}
|
||||
|
||||
// Настройки show the assistant's model as it is set, key included.
|
||||
func TestSettingsShowAI(t *testing.T) {
|
||||
s, c := newTestServer(t)
|
||||
_ = s.db.SetConfig(cfgAIKey, "key-123")
|
||||
body := get(t, s, c, "/settings?frag=1").Body.String()
|
||||
if !strings.Contains(body, `value="key-123"`) || !strings.Contains(body, `value="`+DefaultAIURL+`"`) {
|
||||
t.Fatal("the AI card is empty")
|
||||
}
|
||||
}
|
||||
|
||||
// The look of the panel is each user's: kept on the server, only known
|
||||
// settings with known values, and given to the page at once.
|
||||
func TestViewPrefs(t *testing.T) {
|
||||
s, c := newTestServer(t)
|
||||
post := func(body string) string {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/panel/account/prefs", strings.NewReader(body))
|
||||
req.AddCookie(c)
|
||||
w := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(w, req)
|
||||
return w.Body.String()
|
||||
}
|
||||
if got := post(`{"tabIcons":"right","termFont":16,"compact":true,"motion":"off","evil":"<x>","fileTime":"maybe"}`); !strings.Contains(got, `"ok":true`) {
|
||||
t.Fatal(got)
|
||||
}
|
||||
got := get(t, s, c, "/api/panel/account/prefs").Body.String()
|
||||
if !strings.Contains(got, `"tabIcons":"right"`) || !strings.Contains(got, `"termFont":16`) || strings.Contains(got, "evil") || strings.Contains(got, "maybe") {
|
||||
t.Fatalf("prefs: %s", got)
|
||||
}
|
||||
if body := get(t, s, c, "/workspace").Body.String(); !strings.Contains(body, `"tabIcons":"right"`) {
|
||||
t.Fatal("the page does not get the prefs")
|
||||
}
|
||||
if got := post(`{"termFont":99}`); !strings.Contains(got, `"ok":true`) || strings.Contains(get(t, s, c, "/api/panel/account/prefs").Body.String(), "99") {
|
||||
t.Fatal("a font size out of range was kept")
|
||||
}
|
||||
post(`{"openHere":"off"}`)
|
||||
if got := get(t, s, c, "/api/panel/account/prefs").Body.String(); !strings.Contains(got, `"openHere":"off"`) {
|
||||
t.Fatalf("openHere off not kept: %s", got)
|
||||
}
|
||||
post(`{"openHere":"sometimes"}`)
|
||||
if got := get(t, s, c, "/api/panel/account/prefs").Body.String(); strings.Contains(got, "openHere") {
|
||||
t.Fatalf("an unknown openHere was kept: %s", got)
|
||||
}
|
||||
}
|
||||
@@ -315,6 +315,13 @@ func readPiece(w http.ResponseWriter, r *http.Request) ([]byte, error) {
|
||||
// file. The answer names the transfer and the piece size; the browser then
|
||||
// PUTs the pieces and asks for the result.
|
||||
func (s *Server) handleFSUploadStart(w http.ResponseWriter, r *http.Request) {
|
||||
s.uploadStart(w, r, proto.TaskFSPut, "")
|
||||
}
|
||||
|
||||
// uploadStart starts a file upload to the agent's task (fs.put on the
|
||||
// machine, sshfs.put onto an SSH host through it, conn naming the
|
||||
// connection).
|
||||
func (s *Server) uploadStart(w http.ResponseWriter, r *http.Request, task, conn string) {
|
||||
agentID, p := r.PathValue("id"), r.URL.Query().Get("path")
|
||||
if !s.fsAgent(w, agentID, true) {
|
||||
return
|
||||
@@ -329,19 +336,21 @@ func (s *Server) handleFSUploadStart(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
x := s.xfers.open(agentID, size)
|
||||
go s.runUpload(x, p)
|
||||
go s.runUpload(x, p, task, conn)
|
||||
data, _ := json.Marshal(map[string]any{"id": x.id, "chunk": xferChunk})
|
||||
writeJSON(w, proto.TaskResult{OK: true, Data: data})
|
||||
}
|
||||
|
||||
// runUpload has the agent write the file and keeps the result for the
|
||||
// browser to collect.
|
||||
func (s *Server) runUpload(x *xfer, p string) {
|
||||
func (s *Server) runUpload(x *xfer, p, task, conn string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), xferTimeout)
|
||||
defer cancel()
|
||||
args := proto.FSXferArgs{Path: p, Xfer: x.id, Size: x.size, Chunk: xferChunk}
|
||||
res, err := s.waitTask(ctx, x.agentID, proto.TaskFSPut, args, xferTimeout)
|
||||
if err == nil && unknownOp(res) {
|
||||
args := proto.FSXferArgs{Path: p, Xfer: x.id, Size: x.size, Chunk: xferChunk, Conn: conn}
|
||||
res, err := s.waitTask(ctx, x.agentID, task, args, xferTimeout)
|
||||
if err == nil && unknownOp(res) && task != proto.TaskFSPut {
|
||||
res = proto.TaskResult{Error: "агент не умеет файлы по SSH - обновите его"}
|
||||
} else if err == nil && unknownOp(res) {
|
||||
res = s.legacyUpload(ctx, x, p)
|
||||
}
|
||||
if err != nil {
|
||||
@@ -502,6 +511,12 @@ func fileHeaders(w http.ResponseWriter, p string, inline bool) {
|
||||
// one response fed by the pieces the agent sends. inline=1 serves pictures,
|
||||
// video and sound for the page to show.
|
||||
func (s *Server) handleFSDownload(w http.ResponseWriter, r *http.Request) {
|
||||
s.download(w, r, proto.TaskFSSend, "")
|
||||
}
|
||||
|
||||
// download sends a file from the agent's task (fs.send, or sshfs.send for
|
||||
// an SSH host through it) as one response.
|
||||
func (s *Server) download(w http.ResponseWriter, r *http.Request, task, conn string) {
|
||||
agentID, p := r.PathValue("id"), r.URL.Query().Get("path")
|
||||
inline := r.URL.Query().Get("inline") != ""
|
||||
if !s.fsAgent(w, agentID, false) {
|
||||
@@ -511,8 +526,8 @@ func (s *Server) handleFSDownload(w http.ResponseWriter, r *http.Request) {
|
||||
defer s.xfers.drop(x.id)
|
||||
defer x.fail("браузер ушёл") // stops the agent if the response ends early
|
||||
|
||||
raw, _ := json.Marshal(proto.FSXferArgs{Path: p, Xfer: x.id, Size: -1, Chunk: xferChunk})
|
||||
taskID, ch := s.hub.CreateTask(agentID, proto.TaskFSSend, raw)
|
||||
raw, _ := json.Marshal(proto.FSXferArgs{Path: p, Xfer: x.id, Size: -1, Chunk: xferChunk, Conn: conn})
|
||||
taskID, ch := s.hub.CreateTask(agentID, task, raw)
|
||||
defer s.hub.DiscardTask(taskID)
|
||||
// The agent's result: an error ends the transfer wherever it stands.
|
||||
results := make(chan proto.TaskResult, 1)
|
||||
@@ -530,6 +545,10 @@ func (s *Server) handleFSDownload(w http.ResponseWriter, r *http.Request) {
|
||||
select {
|
||||
case <-x.sized:
|
||||
case res := <-results:
|
||||
if unknownOp(res) && task != proto.TaskFSSend {
|
||||
http.Error(w, "агент не умеет файлы по SSH - обновите его", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
if unknownOp(res) {
|
||||
s.legacyDownload(w, r, agentID, p, inline)
|
||||
return
|
||||
|
||||
@@ -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
|
||||
// present. Agents older than 0.0.2 send no user, so keep what we have.
|
||||
s.viaDirectA.Store(a.ID, s.viaDirect(r))
|
||||
v := r.Header.Get("X-Agent-Version")
|
||||
u := firstNonEmpty(r.Header.Get("X-Agent-User"), a.RunUser)
|
||||
if v != "" && (v != a.Version || u != a.RunUser) {
|
||||
@@ -53,8 +54,9 @@ func (s *Server) handlePoll(w http.ResponseWriter, r *http.Request) {
|
||||
_ = s.db.TouchAgent(a.ID)
|
||||
}
|
||||
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)
|
||||
w.Header().Set(proto.TransportHeader, s.transportOf(a.ID))
|
||||
writeJSON(w, msg)
|
||||
}
|
||||
|
||||
@@ -66,16 +68,22 @@ func (s *Server) setTunnelHeader(w http.ResponseWriter) {
|
||||
if addr := s.tunnelAdvert(); addr != "" {
|
||||
w.Header().Set(tunnelAddrHeader, addr)
|
||||
}
|
||||
if d := s.db.DirectAddress(); d != "" {
|
||||
w.Header().Set(proto.FallbackHeader, d)
|
||||
w.Header().Set(proto.MainHeader, s.db.HostAddress())
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/agent/config - settings an agent picks up when it connects, without
|
||||
// waiting out a poll hold.
|
||||
func (s *Server) handleAgentConfig(w http.ResponseWriter, r *http.Request) {
|
||||
if s.authenticateAgent(r) == nil {
|
||||
a := s.authenticateAgent(r)
|
||||
if a == nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
s.setTunnelHeader(w)
|
||||
w.Header().Set(proto.TransportHeader, s.transportOf(a.ID))
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"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)
|
||||
if admin == nil || !checkPassword(admin.PassHash, pw) {
|
||||
s.limiter.fin(ip, false)
|
||||
s.render(w, "login.html", map[string]any{"Error": "Неверный логин или пароль."})
|
||||
s.render(w, "login.html", map[string]any{"Error": "Неверный логин или пароль.", "Username": username})
|
||||
return
|
||||
}
|
||||
// An admin who turned the password off signs in with a passkey only.
|
||||
if s.db.PasswordOff(admin.ID) {
|
||||
s.render(w, "login.html", map[string]any{"Error": "Для этого аккаунта вход только по ключу доступа.", "Username": username})
|
||||
return
|
||||
}
|
||||
s.limiter.fin(ip, true)
|
||||
@@ -106,26 +112,32 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
// --- dashboard ---
|
||||
|
||||
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
agents, err := s.db.ListAgents()
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.renderPage(w, r, "dashboard", s.dashboardData(r))
|
||||
}
|
||||
|
||||
// dashboardData is the machines list as the user may see it.
|
||||
func (s *Server) dashboardData(r *http.Request) map[string]any {
|
||||
agents, _ := s.db.ListAgents()
|
||||
type row struct {
|
||||
Agent
|
||||
Online bool
|
||||
Seen string
|
||||
}
|
||||
me := s.currentAdmin(r)
|
||||
rows := make([]row, 0, len(agents))
|
||||
for _, a := range agents {
|
||||
if !s.db.CanUseAgent(me, a.ID) {
|
||||
continue
|
||||
}
|
||||
on := s.online(&a)
|
||||
rows = append(rows, row{Agent: a, Online: on, Seen: seenText(on, a.LastSeen)})
|
||||
}
|
||||
s.render(w, "dashboard.html", map[string]any{
|
||||
"Admin": s.currentAdmin(r),
|
||||
"Agents": rows,
|
||||
"CSRF": csrfToken(r),
|
||||
})
|
||||
return map[string]any{
|
||||
"Admin": me,
|
||||
"IsAdmin": me.IsAdmin(),
|
||||
"Agents": rows,
|
||||
"CSRF": csrfToken(r),
|
||||
}
|
||||
}
|
||||
|
||||
// seenText is the "activity" cell: a connected agent is active now, whatever
|
||||
@@ -146,31 +158,25 @@ func (s *Server) handlePresence(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
type item struct {
|
||||
ID string `json:"id"`
|
||||
Online bool `json:"online"`
|
||||
Seen string `json:"seen"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Hostname string `json:"hostname"`
|
||||
User string `json:"user"` // who a terminal opens as
|
||||
Online bool `json:"online"`
|
||||
Seen string `json:"seen"`
|
||||
}
|
||||
me := s.currentAdmin(r)
|
||||
out := make([]item, 0, len(agents))
|
||||
for _, a := range agents {
|
||||
if !s.db.CanUseAgent(me, a.ID) {
|
||||
continue
|
||||
}
|
||||
on := s.online(&a)
|
||||
out = append(out, item{ID: a.ID, 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)
|
||||
}
|
||||
|
||||
func (s *Server) handleTerminalPage(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
a, err := s.db.GetAgent(id)
|
||||
if err != nil || a == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
s.render(w, "terminal.html", map[string]any{
|
||||
"Agent": a,
|
||||
"CSRF": csrfToken(r),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleAgentDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if !checkCSRF(r) {
|
||||
http.Error(w, "bad csrf", http.StatusForbidden)
|
||||
@@ -199,12 +205,19 @@ func (s *Server) handleAgentEdit(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
data := map[string]any{
|
||||
"Agent": a,
|
||||
"IsAdmin": s.currentAdmin(r).IsAdmin(),
|
||||
"CSRF": csrfToken(r),
|
||||
"ServerVersion": Version,
|
||||
"Outdated": a.Version != "" && a.Version != Version,
|
||||
"Updated": r.URL.Query().Get("updated") == "1",
|
||||
"UpdateMsg": r.URL.Query().Get("umsg"),
|
||||
"UpdateErr": r.URL.Query().Get("uperr"),
|
||||
"UserOK": r.URL.Query().Get("uok"),
|
||||
"UserErr": r.URL.Query().Get("uerr"),
|
||||
"Transport": firstNonEmpty(a.Transport, proto.TransportAuto),
|
||||
"OnWS": s.wsAgents.On(a.ID),
|
||||
"ViaDirect": viaDirectOf(&s.viaDirectA, a.ID),
|
||||
"Online": s.online(a),
|
||||
}
|
||||
// The user list lives on the machine, so it is only known while it is online.
|
||||
if s.online(a) {
|
||||
@@ -214,7 +227,22 @@ func (s *Server) handleAgentEdit(w http.ResponseWriter, r *http.Request) {
|
||||
data["UsersErr"] = err.Error()
|
||||
}
|
||||
}
|
||||
s.render(w, "edit.html", data)
|
||||
s.renderPage(w, r, "edit", 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.
|
||||
@@ -340,17 +368,28 @@ func (s *Server) handleAgentUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
back := func(q url.Values) { http.Redirect(w, r, "/agents/"+id+"/edit?"+q.Encode(), http.StatusSeeOther) }
|
||||
if !s.online(a) {
|
||||
http.Redirect(w, r, "/agents/"+id+"/edit?updated=0", http.StatusSeeOther)
|
||||
back(url.Values{"uperr": {"машина не в сети"}})
|
||||
return
|
||||
}
|
||||
taskID, ch := s.hub.CreateTask(id, proto.TaskSysUpdate, nil)
|
||||
defer s.hub.DiscardTask(taskID)
|
||||
select {
|
||||
case <-ch:
|
||||
case <-time.After(30 * time.Second):
|
||||
if admin := s.currentAdmin(r); admin != nil {
|
||||
s.db.Audit(admin.ID, id, "update:"+a.Version+" -> "+Version)
|
||||
}
|
||||
// The download can take a while on a slow link; under a proxy's timeout.
|
||||
res, err := s.waitTask(r.Context(), id, proto.TaskSysUpdate, nil, 80*time.Second)
|
||||
switch {
|
||||
case err != nil:
|
||||
back(url.Values{"uperr": {"агент не ответил: " + err.Error()}})
|
||||
case !res.OK:
|
||||
back(url.Values{"uperr": {res.Error}})
|
||||
default:
|
||||
var d struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
_ = json.Unmarshal(res.Data, &d)
|
||||
back(url.Values{"updated": {"1"}, "umsg": {d.Status}})
|
||||
}
|
||||
http.Redirect(w, r, "/agents/"+id+"/edit?updated=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleAgentRename(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -363,28 +402,51 @@ func (s *Server) handleAgentRename(w http.ResponseWriter, r *http.Request) {
|
||||
if name != "" {
|
||||
_ = s.db.RenameAgent(id, name)
|
||||
}
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
// Back to «Изменить», with the new name: the machine dialog stays open.
|
||||
http.Redirect(w, r, "/agents/"+id+"/edit", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// --- settings ---
|
||||
|
||||
func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
|
||||
admins, _ := s.db.ListAdmins()
|
||||
keyCounts, passOff, agentCounts := map[int64]int{}, map[int64]bool{}, map[int64]int{}
|
||||
for _, a := range admins {
|
||||
keyCounts[a.ID] = s.db.CountAdminKeys(a.ID)
|
||||
passOff[a.ID] = s.db.PasswordOff(a.ID)
|
||||
agentCounts[a.ID] = len(s.db.UserAgents(a.ID))
|
||||
}
|
||||
allAgents, _ := s.db.ListAgents()
|
||||
host := s.db.HostAddress()
|
||||
direct := s.db.DirectAddress()
|
||||
secret := s.db.EnrollSecret()
|
||||
code := s.db.InstallCode()
|
||||
ufwInstalled, ufwActive, ufwRunnable := s.ufw.status()
|
||||
ufwRules, _ := s.db.ListUfwRules()
|
||||
s.render(w, "settings.html", map[string]any{
|
||||
dbNow, dbChosen := s.db.Backend()
|
||||
s.renderPage(w, r, "settings", map[string]any{
|
||||
"Admin": s.currentAdmin(r),
|
||||
"Host": host,
|
||||
"Direct": direct,
|
||||
"DirectShort": fmt.Sprintf("wget -qO- %s/i/%s | sudo sh", direct, code),
|
||||
"DirectCurl": fmt.Sprintf("curl -fsSL %s/install.sh | sudo HOST=%s FALLBACK_HOST=%s ENROLL_SECRET=%s sh", direct, direct, host, secret),
|
||||
"Secret": secret,
|
||||
"CodeLen": s.db.InstallCodeLen(),
|
||||
"Admins": admins,
|
||||
"KeyCounts": keyCounts,
|
||||
"AgentCounts": agentCounts,
|
||||
"AllAgents": allAgents,
|
||||
"Me": s.currentAdmin(r),
|
||||
"PassOff": passOff,
|
||||
"CSRF": csrfToken(r),
|
||||
"InstallShort": fmt.Sprintf("wget -qO- %s/i/%s | sudo sh", host, code),
|
||||
"InstallCurl": fmt.Sprintf("curl -fsSL %s/install.sh | sudo HOST=%s ENROLL_SECRET=%s sh", host, host, secret),
|
||||
"InstallWget": fmt.Sprintf("wget -qO- %s/install.sh | sudo HOST=%s ENROLL_SECRET=%s sh", host, host, secret),
|
||||
"ServerVersion": Version,
|
||||
"AI": s.aiConfig(),
|
||||
"Search": s.searchConf(),
|
||||
"DBNow": dbNow,
|
||||
"DBChosen": dbChosen,
|
||||
"UfwInstalled": ufwInstalled,
|
||||
"UfwActive": ufwActive,
|
||||
"UfwRunnable": ufwRunnable,
|
||||
@@ -397,6 +459,7 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
|
||||
"TunnelCustom": s.db.TunnelPort() > 0,
|
||||
"Error": map[string]string{
|
||||
"tunnel_host": "Некорректный хост туннеля: нужен IP или имя без схемы и порта.",
|
||||
"host": "Некорректный адрес: нужен URL вида https://panel.example.com, без пути.",
|
||||
"tunnel_port": "Некорректный порт туннеля.",
|
||||
"ufw_port": "Некорректное правило ufw: нужен порт 1-65535 и протокол tcp или udp.",
|
||||
"ufw_off": "Правило ufw не добавлено: ufw выключен или интеграция с ним отключена.",
|
||||
@@ -470,9 +533,20 @@ func (s *Server) handleSetHost(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
host := strings.TrimRight(strings.TrimSpace(r.FormValue("host")), "/")
|
||||
direct := strings.TrimRight(strings.TrimSpace(r.FormValue("direct")), "/")
|
||||
for _, v := range []string{host, direct} {
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
if u, err := url.Parse(v); err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || (u.Path != "" && u.Path != "/") {
|
||||
http.Redirect(w, r, "/settings?err=host", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
}
|
||||
if host != "" {
|
||||
_ = s.db.SetConfig(cfgHostAddress, host)
|
||||
}
|
||||
_ = s.db.SetConfig(cfgDirectAddr, direct)
|
||||
http.Redirect(w, r, "/settings", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -483,7 +557,22 @@ func (s *Server) handleRotateSecret(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
_ = s.db.SetConfig(cfgEnrollSecret, randToken())
|
||||
// Rotate the short install code too so old /i/<code> links stop working.
|
||||
_ = s.db.SetConfig(cfgInstallCode, shortCode())
|
||||
_ = s.db.SetConfig(cfgInstallCode, shortCode(s.db.InstallCodeLen()))
|
||||
http.Redirect(w, r, "/settings", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// POST /settings/install/code - a new install code, of the length given:
|
||||
// the old /i/<code> link stops working.
|
||||
func (s *Server) handleInstallCode(w http.ResponseWriter, r *http.Request) {
|
||||
if !checkCSRF(r) {
|
||||
http.Error(w, "bad csrf", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if n, err := strconv.Atoi(r.FormValue("len")); err == nil {
|
||||
n = min(installCodeMax, max(installCodeMin, n))
|
||||
_ = s.db.SetConfig(cfgInstallLen, strconv.Itoa(n))
|
||||
}
|
||||
_ = s.db.SetConfig(cfgInstallCode, shortCode(s.db.InstallCodeLen()))
|
||||
http.Redirect(w, r, "/settings", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -494,12 +583,21 @@ func (s *Server) handleAddAdmin(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
username := strings.TrimSpace(r.FormValue("username"))
|
||||
pw := r.FormValue("password")
|
||||
role := RoleAdmin
|
||||
if r.FormValue("role") == RoleUser {
|
||||
role = RoleUser
|
||||
}
|
||||
if username != "" && len(pw) >= 8 {
|
||||
if hash, err := hashPassword(pw); err == nil {
|
||||
_ = s.db.CreateAdmin(username, hash)
|
||||
if id, err := s.db.CreateUser(username, hash, role); err == nil && role == RoleUser {
|
||||
_ = s.db.SetUserAgents(id, r.Form["agents"])
|
||||
}
|
||||
if me := s.currentAdmin(r); me != nil {
|
||||
s.db.Audit(me.ID, "", "user_add:"+username+":"+role)
|
||||
}
|
||||
}
|
||||
}
|
||||
http.Redirect(w, r, "/settings", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/settings#users", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteAdmin(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -509,7 +607,7 @@ func (s *Server) handleDeleteAdmin(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
id, _ := strconv.ParseInt(r.FormValue("id"), 10, 64)
|
||||
// Never delete the last admin.
|
||||
if n, _ := s.db.CountAdmins(); n > 1 {
|
||||
if u, _ := s.db.AdminByID(id); u != nil && (!u.IsAdmin() || s.db.CountRole(RoleAdmin) > 1) {
|
||||
_ = s.db.DeleteAdmin(id)
|
||||
}
|
||||
http.Redirect(w, r, "/settings", http.StatusSeeOther)
|
||||
@@ -562,7 +660,7 @@ func (s *Server) handleUfwRevoke(w http.ResponseWriter, r *http.Request) {
|
||||
// handleInstallScript serves the generic installer; the secret is NOT baked in,
|
||||
// so it must be supplied via env or the interactive prompt.
|
||||
func (s *Server) handleInstallScript(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderInstall(w, "")
|
||||
s.renderInstall(w, r, "")
|
||||
}
|
||||
|
||||
// handleShortInstall serves the installer behind the short /i/<code> link with
|
||||
@@ -570,21 +668,49 @@ func (s *Server) handleInstallScript(w http.ResponseWriter, r *http.Request) {
|
||||
//
|
||||
// wget -qO- https://host/i/<code> | sudo sh
|
||||
func (s *Server) handleShortInstall(w http.ResponseWriter, r *http.Request) {
|
||||
// A short code can be guessed: wrong ones count against the address
|
||||
// like wrong passwords do.
|
||||
ip := clientIP(r)
|
||||
if s.limiter.blocked(ip) {
|
||||
http.Error(w, "too many attempts", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
code := r.PathValue("code")
|
||||
want := s.db.InstallCode()
|
||||
if want == "" || !constantTimeEqual(code, want) {
|
||||
s.limiter.fin(ip, false)
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
s.renderInstall(w, s.db.EnrollSecret())
|
||||
s.renderInstall(w, r, s.db.EnrollSecret())
|
||||
}
|
||||
|
||||
// renderInstall writes install.sh with the current host and an optional secret.
|
||||
func (s *Server) renderInstall(w http.ResponseWriter, secret string) {
|
||||
// viaDirect reports whether the request came to the direct address.
|
||||
func (s *Server) viaDirect(r *http.Request) bool {
|
||||
d := directHost(s.db.DirectAddress())
|
||||
if d == "" {
|
||||
return false
|
||||
}
|
||||
host := r.Host
|
||||
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||
host = h
|
||||
}
|
||||
return strings.EqualFold(host, d)
|
||||
}
|
||||
|
||||
// renderInstall writes install.sh with the server's addresses and an
|
||||
// optional secret. Fetched from the direct address, it makes that the
|
||||
// agent's address and the main one its fallback; otherwise the other way.
|
||||
func (s *Server) renderInstall(w http.ResponseWriter, r *http.Request, secret string) {
|
||||
host, fallback := s.db.HostAddress(), s.db.DirectAddress()
|
||||
if s.viaDirect(r) {
|
||||
host, fallback = fallback, host
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/x-shellscript; charset=utf-8")
|
||||
if err := s.installTmpl.Execute(w, map[string]any{
|
||||
"Host": s.db.HostAddress(),
|
||||
"Secret": secret,
|
||||
"Host": host,
|
||||
"Fallback": fallback,
|
||||
"Secret": secret,
|
||||
}); err != nil {
|
||||
log.Printf("install.sh render: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
@@ -596,3 +722,11 @@ func (s *Server) downloadHandler() http.Handler {
|
||||
// install one-liner works before the machine is enrolled.
|
||||
return http.FileServer(http.Dir(s.distDir))
|
||||
}
|
||||
|
||||
// viaDirectOf tells whether the agent last reached the server at the direct
|
||||
// address.
|
||||
func viaDirectOf(m *sync.Map, id string) bool {
|
||||
v, _ := m.Load(id)
|
||||
b, _ := v.(bool)
|
||||
return b
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
@@ -10,30 +11,30 @@ import (
|
||||
|
||||
// fsOps maps the URL op segment to a proto task type.
|
||||
var fsOps = map[string]string{
|
||||
"list": proto.TaskFSList,
|
||||
"read": proto.TaskFSRead,
|
||||
"write": proto.TaskFSWrite,
|
||||
"mkdir": proto.TaskFSMkdir,
|
||||
"delete": proto.TaskFSDelete,
|
||||
"deltree": proto.TaskFSDelTree,
|
||||
"unzip": proto.TaskFSUnzip,
|
||||
"zip": proto.TaskFSZip,
|
||||
"move": proto.TaskFSMove,
|
||||
"list": proto.TaskFSList,
|
||||
"read": proto.TaskFSRead,
|
||||
"write": proto.TaskFSWrite,
|
||||
"mkdir": proto.TaskFSMkdir,
|
||||
"delete": proto.TaskFSDelete,
|
||||
"deltree": proto.TaskFSDelTree,
|
||||
"unzip": proto.TaskFSUnzip,
|
||||
"zip": proto.TaskFSZip,
|
||||
"move": proto.TaskFSMove,
|
||||
"stat": proto.TaskFSStat,
|
||||
"chmod": proto.TaskFSChmod,
|
||||
"copy": proto.TaskFSCopy,
|
||||
"db.tables": proto.TaskDBTables,
|
||||
"db.rows": proto.TaskDBRows,
|
||||
"db.set": proto.TaskDBSet,
|
||||
"db.add": proto.TaskDBAdd,
|
||||
"db.del": proto.TaskDBDel,
|
||||
"db.sql": proto.TaskDBSQL,
|
||||
"termcwd": proto.TaskTermCwd,
|
||||
}
|
||||
|
||||
// fsTaskTimeout bounds how long we wait for an agent to answer a file op.
|
||||
const fsTaskTimeout = 60 * time.Second
|
||||
|
||||
func (s *Server) handleFilesPage(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
a, err := s.db.GetAgent(id)
|
||||
if err != nil || a == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
s.render(w, "files.html", map[string]any{"Agent": a, "EditLimit": proto.FSReadCap})
|
||||
}
|
||||
|
||||
// POST /api/panel/agents/{id}/fs/{op} - run a file-manager operation on the
|
||||
// agent and return its result. The request body is the op's JSON args and is
|
||||
// forwarded verbatim to the agent.
|
||||
@@ -60,6 +61,26 @@ func (s *Server) handleFSOp(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Changes to a database go to the audit log, with what was done.
|
||||
if op == "db.set" || op == "db.add" || op == "db.del" || op == "db.sql" {
|
||||
var d struct {
|
||||
Path, Table, Column, SQL string
|
||||
}
|
||||
_ = json.Unmarshal(args, &d)
|
||||
what := d.Path + " " + d.Table
|
||||
if d.Column != "" {
|
||||
what += "." + d.Column
|
||||
}
|
||||
if d.SQL != "" {
|
||||
what += " " + d.SQL
|
||||
}
|
||||
if len(what) > 300 {
|
||||
what = what[:300]
|
||||
}
|
||||
if admin := s.currentAdmin(r); admin != nil {
|
||||
s.db.Audit(admin.ID, agentID, op+":"+what)
|
||||
}
|
||||
}
|
||||
taskID, resultCh := s.hub.CreateTask(agentID, taskType, args)
|
||||
defer s.hub.DiscardTask(taskID)
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ func (s *Server) handleForwardsPage(w http.ResponseWriter, r *http.Request) {
|
||||
"listen": "Не удалось занять порт на хосте (возможно, уже используется).",
|
||||
"db": "Ошибка базы данных.",
|
||||
}[r.URL.Query().Get("err")]
|
||||
s.render(w, "forwards.html", map[string]any{
|
||||
s.renderPage(w, r, "forwards", map[string]any{
|
||||
"Agent": a,
|
||||
"Rules": rules,
|
||||
"CSRF": csrfToken(r),
|
||||
@@ -114,6 +114,10 @@ func (s *Server) handleForwardDelete(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
rid, _ := strconv.ParseInt(r.PathValue("rid"), 10, 64)
|
||||
rule, _ := s.db.GetForwardRule(rid)
|
||||
if rule == nil || !s.db.CanUseAgent(s.currentAdmin(r), rule.AgentID) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
s.removeForward(rid)
|
||||
dest := "/"
|
||||
if rule != nil {
|
||||
|
||||
@@ -74,7 +74,7 @@ func (s *Server) handleStatusPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
s.render(w, "status.html", map[string]any{"Agent": a, "RelayUpMs": speedRelayUpFor.Milliseconds()})
|
||||
s.renderPage(w, r, "status", map[string]any{"Agent": a, "RelayUpMs": speedRelayUpFor.Milliseconds()})
|
||||
}
|
||||
|
||||
// runTask runs a one-shot task on the agent and waits for its result.
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/coder/websocket/wsjson"
|
||||
|
||||
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||
)
|
||||
@@ -12,7 +21,10 @@ import (
|
||||
// same transport the agent uses), so it works through any reverse proxy without
|
||||
// WebSocket upgrade support.
|
||||
|
||||
// POST /api/panel/term/{agentID}/open - create a terminal session.
|
||||
// POST /api/panel/term/{agentID}/open - create a terminal session: a shell
|
||||
// on the agent, or with {"ssh": {...}} in the body a shell the agent opens
|
||||
// by SSH on another host. {"dir": "/path"} starts the shell in that folder
|
||||
// (an older agent, or a folder not there: at home).
|
||||
func (s *Server) handlePanelOpen(w http.ResponseWriter, r *http.Request) {
|
||||
agentID := r.PathValue("agentID")
|
||||
agent, err := s.db.GetAgent(agentID)
|
||||
@@ -21,7 +33,43 @@ func (s *Server) handlePanelOpen(w http.ResponseWriter, r *http.Request) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
sess := s.hub.CreateSession(agentID, agent.LoginUser)
|
||||
var body struct {
|
||||
SSH *proto.SSHTarget `json:"ssh"`
|
||||
Dir string `json:"dir"`
|
||||
}
|
||||
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 err := s.fillSaved(s.currentAdmin(r).ID, body.SSH); err != nil {
|
||||
writeJSON(w, map[string]any{"error": err.Error()})
|
||||
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
|
||||
}
|
||||
dir := body.Dir
|
||||
if !strings.HasPrefix(dir, "/") || strings.ContainsAny(dir, "\x00\r\n") || len(dir) > 4096 {
|
||||
dir = ""
|
||||
}
|
||||
sess := s.hub.CreateSession(agentID, agent.LoginUser, dir)
|
||||
if admin := s.currentAdmin(r); admin != nil {
|
||||
s.db.Audit(admin.ID, agentID, "open_terminal:"+agent.TerminalUser())
|
||||
}
|
||||
@@ -66,8 +114,14 @@ func (s *Server) handlePanelIn(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
takePanelIn(sess, m)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// takePanelIn passes the browser's keystrokes or resize on to the agent; a
|
||||
// repeat of one already taken is dropped.
|
||||
func takePanelIn(sess *Session, m panelInMsg) {
|
||||
if m.Seq > 0 && !sess.TakeSeq(m.Seq) {
|
||||
w.WriteHeader(http.StatusNoContent) // a repeat of one already taken
|
||||
return
|
||||
}
|
||||
switch m.T {
|
||||
@@ -76,7 +130,72 @@ func (s *Server) handlePanelIn(w http.ResponseWriter, r *http.Request) {
|
||||
case "r":
|
||||
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
|
||||
}
|
||||
// Terminal text packs well: deflate with the context kept between
|
||||
// messages, for the larger ones (screens of output, not keystrokes).
|
||||
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{CompressionMode: websocket.CompressionContextTakeover, CompressionThreshold: 256})
|
||||
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.
|
||||
|
||||
+29
-2
@@ -94,9 +94,16 @@ func (h *Hub) newSession(agentID string) *Session {
|
||||
|
||||
// CreateSession registers a terminal session and queues it for the agent. user
|
||||
// is the OS user to run the shell as ("" = the agent's own user).
|
||||
func (h *Hub) CreateSession(agentID, user string) *Session {
|
||||
func (h *Hub) CreateSession(agentID, user, dir string) *Session {
|
||||
s := h.newSession(agentID)
|
||||
h.enqueueJob(agentID, proto.ControlMessage{Type: proto.CtrlOpenTerminal, SessionID: s.ID, User: user})
|
||||
h.enqueueJob(agentID, proto.ControlMessage{Type: proto.CtrlOpenTerminal, SessionID: s.ID, User: user, Dir: dir})
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
func (s *Session) PollIn(timeout time.Duration) ([]proto.InFrame, bool) {
|
||||
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.
|
||||
func (s *Session) PollOut(timeout time.Duration) ([]proto.OutMessage, bool) {
|
||||
return s.PollOutUntil(nil, timeout)
|
||||
}
|
||||
|
||||
// PollOutUntil is PollOut that also gives up when done closes, taking
|
||||
// nothing: for a reader that has gone, so no output is lost on it.
|
||||
func (s *Session) PollOutUntil(done <-chan struct{}, timeout time.Duration) ([]proto.OutMessage, bool) {
|
||||
var batch []proto.OutMessage
|
||||
select {
|
||||
case m := <-s.out:
|
||||
batch = append(batch, m)
|
||||
case <-done:
|
||||
return nil, true
|
||||
case <-time.After(timeout):
|
||||
return nil, true
|
||||
case <-s.closed:
|
||||
|
||||
@@ -0,0 +1,644 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/ed25519"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Passkeys (WebAuthn): an admin signs in with a key held by the device - a
|
||||
// fingerprint, the phone's lock, a hardware key - instead of a password. With
|
||||
// a key in place, the admin can turn the password off in their account.
|
||||
//
|
||||
// Attestation is not checked ("none"): the panel trusts any authenticator,
|
||||
// it only needs the public key and, at sign-in, a signature made with it.
|
||||
|
||||
var b64u = base64.RawURLEncoding
|
||||
|
||||
// challenges are the ones handed out and not yet used, a few minutes each.
|
||||
type challenges struct {
|
||||
mu sync.Mutex
|
||||
m map[string]challenge
|
||||
}
|
||||
|
||||
type challenge struct {
|
||||
adminID int64 // the admin registering a key; 0 for a sign-in
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
func (c *challenges) issue(adminID int64) string {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic("crypto/rand failed: " + err.Error())
|
||||
}
|
||||
id := b64u.EncodeToString(b)
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.m == nil {
|
||||
c.m = map[string]challenge{}
|
||||
}
|
||||
for k, v := range c.m {
|
||||
if time.Now().After(v.expires) {
|
||||
delete(c.m, k)
|
||||
}
|
||||
}
|
||||
c.m[id] = challenge{adminID: adminID, expires: time.Now().Add(5 * time.Minute)}
|
||||
return id
|
||||
}
|
||||
|
||||
// take uses up a challenge: true when it was issued, for this admin, and is
|
||||
// still fresh.
|
||||
func (c *challenges) take(id string, adminID int64) bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
ch, ok := c.m[id]
|
||||
delete(c.m, id)
|
||||
return ok && ch.adminID == adminID && time.Now().Before(ch.expires)
|
||||
}
|
||||
|
||||
// mainHost is the main address's host name, set when the server starts.
|
||||
var mainHost = func() string { return "" }
|
||||
|
||||
// rpID is the site the keys belong to: the host the browser is on, or the
|
||||
// main address's host when the browser is on it or under it - so the keys
|
||||
// made on lpra.example.com also sign in on direct.lpra.example.com.
|
||||
func rpID(r *http.Request) string {
|
||||
host := r.Host
|
||||
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||
host = h
|
||||
}
|
||||
if m := mainHost(); m != "" && strings.HasSuffix(host, "."+m) {
|
||||
return m
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// checkClientData verifies what the browser signed over: the kind of
|
||||
// ceremony, our challenge and our origin. A proxy in front may speak plain
|
||||
// http to us, so the origin is matched by host, and must be https unless the
|
||||
// panel is on this machine.
|
||||
func checkClientData(r *http.Request, raw []byte, typ string, adminID int64, ch *challenges) error {
|
||||
var cd struct {
|
||||
Type string `json:"type"`
|
||||
Challenge string `json:"challenge"`
|
||||
Origin string `json:"origin"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &cd); err != nil {
|
||||
return errors.New("ответ ключа не читается")
|
||||
}
|
||||
if cd.Type != typ {
|
||||
return errors.New("ответ ключа не того вида")
|
||||
}
|
||||
if !ch.take(cd.Challenge, adminID) {
|
||||
return errors.New("запрос устарел - попробуйте ещё раз")
|
||||
}
|
||||
u, err := url.Parse(cd.Origin)
|
||||
if err != nil || u.Host != r.Host {
|
||||
return errors.New("ключ подписал запрос для другого сайта")
|
||||
}
|
||||
local := u.Hostname() == "localhost" || u.Hostname() == "127.0.0.1" || u.Hostname() == "::1"
|
||||
if u.Scheme != "https" && !local {
|
||||
return errors.New("ключи доступа работают только по https")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// authData is the authenticator's part: rpIdHash, flags, counter, then the
|
||||
// new credential at registration.
|
||||
type authData struct {
|
||||
rpIDHash []byte
|
||||
flags byte
|
||||
signCount uint32
|
||||
credID []byte
|
||||
coseKey []byte
|
||||
}
|
||||
|
||||
func parseAuthData(b []byte, wantCred bool) (*authData, error) {
|
||||
if len(b) < 37 {
|
||||
return nil, errors.New("данные ключа слишком короткие")
|
||||
}
|
||||
ad := &authData{rpIDHash: b[:32], flags: b[32], signCount: binary.BigEndian.Uint32(b[33:37])}
|
||||
if !wantCred {
|
||||
return ad, nil
|
||||
}
|
||||
if ad.flags&0x40 == 0 || len(b) < 37+18 {
|
||||
return nil, errors.New("ключ не прислал себя")
|
||||
}
|
||||
n := int(binary.BigEndian.Uint16(b[53:55]))
|
||||
if len(b) < 55+n {
|
||||
return nil, errors.New("данные ключа обрезаны")
|
||||
}
|
||||
ad.credID = b[55 : 55+n]
|
||||
ad.coseKey = b[55+n:]
|
||||
return ad, nil
|
||||
}
|
||||
|
||||
func (ad *authData) check(r *http.Request) error {
|
||||
sum := sha256.Sum256([]byte(rpID(r)))
|
||||
if !bytes.Equal(ad.rpIDHash, sum[:]) {
|
||||
return errors.New("ключ создан для другого сайта")
|
||||
}
|
||||
if ad.flags&0x01 == 0 {
|
||||
return errors.New("ключ не подтвердил присутствие")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// coseToPKIX turns a COSE public key (ES256, RS256 or EdDSA) into PKIX DER.
|
||||
func coseToPKIX(raw []byte) ([]byte, error) {
|
||||
v, _, err := cborDecode(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m, ok := v.(map[any]any)
|
||||
if !ok {
|
||||
return nil, errors.New("ключ не в формате COSE")
|
||||
}
|
||||
num := func(k int64) int64 { n, _ := m[k].(int64); return n }
|
||||
bin := func(k int64) []byte { b, _ := m[k].([]byte); return b }
|
||||
var pub any
|
||||
switch num(1) { // kty
|
||||
case 2: // EC2
|
||||
x, y := bin(-2), bin(-3)
|
||||
if num(-1) != 1 || len(x) != 32 || len(y) != 32 {
|
||||
return nil, errors.New("поддерживается только кривая P-256")
|
||||
}
|
||||
k := &ecdsa.PublicKey{Curve: elliptic.P256(), X: new(big.Int).SetBytes(x), Y: new(big.Int).SetBytes(y)}
|
||||
if !k.Curve.IsOnCurve(k.X, k.Y) {
|
||||
return nil, errors.New("ключ не на кривой")
|
||||
}
|
||||
pub = k
|
||||
case 3: // RSA
|
||||
n, e := bin(-1), bin(-2)
|
||||
if len(n) < 256 || len(e) == 0 || len(e) > 4 {
|
||||
return nil, errors.New("неподходящий ключ RSA")
|
||||
}
|
||||
pub = &rsa.PublicKey{N: new(big.Int).SetBytes(n), E: int(new(big.Int).SetBytes(e).Int64())}
|
||||
case 1: // OKP
|
||||
x := bin(-2)
|
||||
if num(-1) != 6 || len(x) != ed25519.PublicKeySize {
|
||||
return nil, errors.New("поддерживается только Ed25519")
|
||||
}
|
||||
pub = ed25519.PublicKey(x)
|
||||
default:
|
||||
return nil, errors.New("неизвестный вид ключа")
|
||||
}
|
||||
return x509.MarshalPKIXPublicKey(pub)
|
||||
}
|
||||
|
||||
func verifySig(der, data, sig []byte) bool {
|
||||
pub, err := x509.ParsePKIXPublicKey(der)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
sum := sha256.Sum256(data)
|
||||
switch k := pub.(type) {
|
||||
case *ecdsa.PublicKey:
|
||||
return ecdsa.VerifyASN1(k, sum[:], sig)
|
||||
case *rsa.PublicKey:
|
||||
return rsa.VerifyPKCS1v15(k, crypto.SHA256, sum[:], sig) == nil
|
||||
case ed25519.PublicKey:
|
||||
return ed25519.Verify(k, data, sig)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ---------- CBOR, as much as WebAuthn needs ----------
|
||||
|
||||
func cborDecode(b []byte) (any, []byte, error) {
|
||||
r := &cborReader{b: b}
|
||||
v, err := r.item(0)
|
||||
return v, r.b[r.i:], err
|
||||
}
|
||||
|
||||
type cborReader struct {
|
||||
b []byte
|
||||
i int
|
||||
}
|
||||
|
||||
var errCBOR = errors.New("данные ключа повреждены")
|
||||
|
||||
func (r *cborReader) arg(info byte) (uint64, error) {
|
||||
size := map[byte]int{24: 1, 25: 2, 26: 4, 27: 8}
|
||||
if info < 24 {
|
||||
return uint64(info), nil
|
||||
}
|
||||
n, ok := size[info]
|
||||
if !ok || r.i+n > len(r.b) {
|
||||
return 0, errCBOR
|
||||
}
|
||||
var v uint64
|
||||
for _, c := range r.b[r.i : r.i+n] {
|
||||
v = v<<8 | uint64(c)
|
||||
}
|
||||
r.i += n
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (r *cborReader) take(n uint64) ([]byte, error) {
|
||||
if n > uint64(len(r.b)-r.i) {
|
||||
return nil, errCBOR
|
||||
}
|
||||
out := r.b[r.i : r.i+int(n)]
|
||||
r.i += int(n)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *cborReader) item(depth int) (any, error) {
|
||||
if r.i >= len(r.b) || depth > 16 {
|
||||
return nil, errCBOR
|
||||
}
|
||||
ib := r.b[r.i]
|
||||
r.i++
|
||||
major, info := ib>>5, ib&31
|
||||
if major == 7 {
|
||||
switch info {
|
||||
case 20:
|
||||
return false, nil
|
||||
case 21:
|
||||
return true, nil
|
||||
case 22, 23:
|
||||
return nil, nil
|
||||
}
|
||||
_, err := r.arg(info) // a float: not used, skipped
|
||||
return nil, err
|
||||
}
|
||||
n, err := r.arg(info)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch major {
|
||||
case 0:
|
||||
return int64(n), nil
|
||||
case 1:
|
||||
return -1 - int64(n), nil
|
||||
case 2:
|
||||
return r.take(n)
|
||||
case 3:
|
||||
s, err := r.take(n)
|
||||
return string(s), err
|
||||
case 4:
|
||||
if n > 1024 {
|
||||
return nil, errCBOR
|
||||
}
|
||||
arr := make([]any, 0, n)
|
||||
for k := uint64(0); k < n; k++ {
|
||||
v, err := r.item(depth + 1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
arr = append(arr, v)
|
||||
}
|
||||
return arr, nil
|
||||
case 5:
|
||||
if n > 1024 {
|
||||
return nil, errCBOR
|
||||
}
|
||||
m := make(map[any]any, n)
|
||||
for k := uint64(0); k < n; k++ {
|
||||
key, err := r.item(depth + 1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v, err := r.item(depth + 1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch key.(type) {
|
||||
case int64, string:
|
||||
m[key] = v
|
||||
default:
|
||||
return nil, errCBOR
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
case 6:
|
||||
return r.item(depth + 1) // a tag: its content
|
||||
}
|
||||
return nil, errCBOR
|
||||
}
|
||||
|
||||
// ---------- handlers ----------
|
||||
|
||||
func credParams() []map[string]any {
|
||||
return []map[string]any{
|
||||
{"type": "public-key", "alg": -7}, // ES256
|
||||
{"type": "public-key", "alg": -8}, // EdDSA
|
||||
{"type": "public-key", "alg": -257}, // RS256
|
||||
}
|
||||
}
|
||||
|
||||
func userHandle(id int64) string {
|
||||
b := make([]byte, 8)
|
||||
binary.BigEndian.PutUint64(b, uint64(id))
|
||||
return b64u.EncodeToString(b)
|
||||
}
|
||||
|
||||
// GET /api/panel/account - who is signed in, and their passkeys.
|
||||
func (s *Server) handleAccount(w http.ResponseWriter, r *http.Request) {
|
||||
a := s.currentAdmin(r)
|
||||
keys, _ := s.db.ListAdminKeys(a.ID)
|
||||
list := []map[string]any{}
|
||||
for _, k := range keys {
|
||||
list = append(list, map[string]any{"id": k.ID, "name": k.Name, "created": k.CreatedAt, "last_used": k.LastUsed})
|
||||
}
|
||||
writeJSON(w, map[string]any{"username": a.Username, "keys": list, "password_off": s.db.PasswordOff(a.ID), "pin": s.db.AdminPin(a.ID) != "",
|
||||
"pw_locked": a.PwLocked, "admin": a.IsAdmin()})
|
||||
}
|
||||
|
||||
// POST /api/panel/account/password/change - a new password, given the
|
||||
// current one.
|
||||
func (s *Server) handlePasswordChange(w http.ResponseWriter, r *http.Request) {
|
||||
a := s.currentAdmin(r)
|
||||
var in struct {
|
||||
Current string `json:"current"`
|
||||
New string `json:"new"`
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&in); err != nil {
|
||||
writeJSON(w, map[string]any{"ok": false, "error": "плохой запрос"})
|
||||
return
|
||||
}
|
||||
ip := clientIP(r)
|
||||
if s.limiter.blocked(ip) {
|
||||
writeJSON(w, map[string]any{"ok": false, "error": "Слишком много попыток. Подождите немного."})
|
||||
return
|
||||
}
|
||||
if !checkPassword(a.PassHash, in.Current) {
|
||||
s.limiter.fin(ip, false)
|
||||
writeJSON(w, map[string]any{"ok": false, "error": "текущий пароль не тот"})
|
||||
return
|
||||
}
|
||||
if len(in.New) < 8 {
|
||||
writeJSON(w, map[string]any{"ok": false, "error": "новый пароль - не короче 8 символов"})
|
||||
return
|
||||
}
|
||||
hash, err := hashPassword(in.New)
|
||||
if err == nil {
|
||||
err = s.db.SetAdminPassword(a.ID, hash)
|
||||
}
|
||||
if err != nil {
|
||||
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
s.limiter.fin(ip, true)
|
||||
s.db.Audit(a.ID, "", "password_change")
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// POST /api/panel/account/password {"off": bool} - turn signing in with the
|
||||
// password off or on again. Off only with a passkey to sign in with.
|
||||
func (s *Server) handlePasswordToggle(w http.ResponseWriter, r *http.Request) {
|
||||
a := s.currentAdmin(r)
|
||||
var in struct {
|
||||
Off bool `json:"off"`
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<10)).Decode(&in); err != nil {
|
||||
writeJSON(w, map[string]any{"ok": false, "error": "плохой запрос"})
|
||||
return
|
||||
}
|
||||
if a.PwLocked {
|
||||
writeJSON(w, map[string]any{"ok": false, "error": "вход по паролю закрепил администратор"})
|
||||
return
|
||||
}
|
||||
if in.Off && s.db.CountAdminKeys(a.ID) == 0 {
|
||||
writeJSON(w, map[string]any{"ok": false, "error": "сначала добавьте ключ доступа"})
|
||||
return
|
||||
}
|
||||
if err := s.db.SetPasswordOff(a.ID, in.Off); err != nil {
|
||||
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
action := "password_on"
|
||||
if in.Off {
|
||||
action = "password_off"
|
||||
}
|
||||
s.db.Audit(a.ID, "", action)
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// POST /api/panel/account/keys/begin - options to make a new passkey with.
|
||||
func (s *Server) handleKeyRegisterBegin(w http.ResponseWriter, r *http.Request) {
|
||||
a := s.currentAdmin(r)
|
||||
keys, _ := s.db.ListAdminKeys(a.ID)
|
||||
exclude := []map[string]any{}
|
||||
for _, k := range keys {
|
||||
exclude = append(exclude, map[string]any{"type": "public-key", "id": b64u.EncodeToString(k.CredID)})
|
||||
}
|
||||
writeJSON(w, map[string]any{"ok": true, "options": map[string]any{
|
||||
"challenge": s.challenges.issue(a.ID),
|
||||
"rp": map[string]any{"id": rpID(r), "name": "LPRA"},
|
||||
"user": map[string]any{"id": userHandle(a.ID), "name": a.Username, "displayName": a.Username},
|
||||
"pubKeyCredParams": credParams(),
|
||||
"excludeCredentials": exclude,
|
||||
"authenticatorSelection": map[string]any{
|
||||
"residentKey": "preferred", "userVerification": "preferred",
|
||||
},
|
||||
"attestation": "none",
|
||||
"timeout": 120000,
|
||||
}})
|
||||
}
|
||||
|
||||
// POST /api/panel/account/keys/finish - keep the passkey the browser made.
|
||||
func (s *Server) handleKeyRegisterFinish(w http.ResponseWriter, r *http.Request) {
|
||||
a := s.currentAdmin(r)
|
||||
var in struct {
|
||||
Name string `json:"name"`
|
||||
ClientDataJSON string `json:"clientDataJSON"`
|
||||
AttestationObject string `json:"attestationObject"`
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&in); err != nil {
|
||||
writeJSON(w, map[string]any{"ok": false, "error": "плохой запрос"})
|
||||
return
|
||||
}
|
||||
fail := func(err error) { writeJSON(w, map[string]any{"ok": false, "error": err.Error()}) }
|
||||
cd, err1 := b64u.DecodeString(in.ClientDataJSON)
|
||||
att, err2 := b64u.DecodeString(in.AttestationObject)
|
||||
if err1 != nil || err2 != nil {
|
||||
fail(errors.New("плохой запрос"))
|
||||
return
|
||||
}
|
||||
if err := checkClientData(r, cd, "webauthn.create", a.ID, &s.challenges); err != nil {
|
||||
fail(err)
|
||||
return
|
||||
}
|
||||
v, _, err := cborDecode(att)
|
||||
m, ok := v.(map[any]any)
|
||||
if err != nil || !ok {
|
||||
fail(errCBOR)
|
||||
return
|
||||
}
|
||||
raw, _ := m["authData"].([]byte)
|
||||
ad, err := parseAuthData(raw, true)
|
||||
if err != nil {
|
||||
fail(err)
|
||||
return
|
||||
}
|
||||
if err := ad.check(r); err != nil {
|
||||
fail(err)
|
||||
return
|
||||
}
|
||||
der, err := coseToPKIX(ad.coseKey)
|
||||
if err != nil {
|
||||
fail(err)
|
||||
return
|
||||
}
|
||||
if k, _ := s.db.AdminKeyByCredID(ad.credID); k != nil {
|
||||
fail(errors.New("этот ключ уже добавлен"))
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(in.Name)
|
||||
if name == "" {
|
||||
name = "ключ " + time.Now().Format("02.01.2006")
|
||||
}
|
||||
if len(name) > 60 {
|
||||
name = name[:60]
|
||||
}
|
||||
if err := s.db.AddAdminKey(AdminKey{AdminID: a.ID, CredID: ad.credID, PublicKey: der, SignCount: ad.signCount, Name: name}); err != nil {
|
||||
fail(err)
|
||||
return
|
||||
}
|
||||
s.db.Audit(a.ID, "", "passkey_add:"+name)
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// POST /api/panel/account/keys/{id}/delete
|
||||
func (s *Server) handleKeyDelete(w http.ResponseWriter, r *http.Request) {
|
||||
a := s.currentAdmin(r)
|
||||
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err := s.db.DeleteAdminKey(a.ID, id); err != nil {
|
||||
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
s.db.Audit(a.ID, "", "passkey_delete:"+strconv.FormatInt(id, 10))
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// POST /api/login/key/begin - options to sign in with a passkey. With a
|
||||
// username, its keys are named, for keys that do not keep the account
|
||||
// themselves.
|
||||
func (s *Server) handleKeyLoginBegin(w http.ResponseWriter, r *http.Request) {
|
||||
if s.limiter.blocked(clientIP(r)) {
|
||||
writeJSON(w, map[string]any{"ok": false, "error": "Слишком много попыток. Подождите немного."})
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
Username string `json:"username"`
|
||||
}
|
||||
_ = json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&in)
|
||||
allow := []map[string]any{}
|
||||
if u := strings.TrimSpace(in.Username); u != "" {
|
||||
if a, _ := s.db.AdminByUsername(u); a != nil {
|
||||
keys, _ := s.db.ListAdminKeys(a.ID)
|
||||
for _, k := range keys {
|
||||
allow = append(allow, map[string]any{"type": "public-key", "id": b64u.EncodeToString(k.CredID)})
|
||||
}
|
||||
}
|
||||
}
|
||||
writeJSON(w, map[string]any{"ok": true, "options": map[string]any{
|
||||
"challenge": s.challenges.issue(0),
|
||||
"rpId": rpID(r),
|
||||
"allowCredentials": allow,
|
||||
"userVerification": "preferred",
|
||||
"timeout": 120000,
|
||||
}})
|
||||
}
|
||||
|
||||
// POST /api/login/key/finish - check the signature and sign the admin in.
|
||||
func (s *Server) handleKeyLoginFinish(w http.ResponseWriter, r *http.Request) {
|
||||
ip := clientIP(r)
|
||||
if s.limiter.blocked(ip) {
|
||||
writeJSON(w, map[string]any{"ok": false, "error": "Слишком много попыток. Подождите немного."})
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
ID string `json:"id"`
|
||||
ClientDataJSON string `json:"clientDataJSON"`
|
||||
AuthenticatorData string `json:"authenticatorData"`
|
||||
Signature string `json:"signature"`
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&in); err != nil {
|
||||
writeJSON(w, map[string]any{"ok": false, "error": "плохой запрос"})
|
||||
return
|
||||
}
|
||||
fail := func(msg string) {
|
||||
s.limiter.fin(ip, false)
|
||||
writeJSON(w, map[string]any{"ok": false, "error": msg})
|
||||
}
|
||||
credID, e1 := b64u.DecodeString(in.ID)
|
||||
cd, e2 := b64u.DecodeString(in.ClientDataJSON)
|
||||
rawAD, e3 := b64u.DecodeString(in.AuthenticatorData)
|
||||
sig, e4 := b64u.DecodeString(in.Signature)
|
||||
if e1 != nil || e2 != nil || e3 != nil || e4 != nil {
|
||||
fail("плохой запрос")
|
||||
return
|
||||
}
|
||||
k, _ := s.db.AdminKeyByCredID(credID)
|
||||
if k == nil {
|
||||
fail("этот ключ здесь не привязан ни к одному аккаунту")
|
||||
return
|
||||
}
|
||||
if err := checkClientData(r, cd, "webauthn.get", 0, &s.challenges); err != nil {
|
||||
fail(err.Error())
|
||||
return
|
||||
}
|
||||
ad, err := parseAuthData(rawAD, false)
|
||||
if err == nil {
|
||||
err = ad.check(r)
|
||||
}
|
||||
if err != nil {
|
||||
fail(err.Error())
|
||||
return
|
||||
}
|
||||
cdHash := sha256.Sum256(cd)
|
||||
if !verifySig(k.PublicKey, append(append([]byte{}, rawAD...), cdHash[:]...), sig) {
|
||||
fail("подпись ключа не сошлась")
|
||||
return
|
||||
}
|
||||
// A counter that goes back means a copy of the key is in use.
|
||||
if ad.signCount != 0 && k.SignCount != 0 && ad.signCount <= k.SignCount {
|
||||
fail("ключ похож на копию: счётчик не вырос")
|
||||
return
|
||||
}
|
||||
_ = s.db.UseAdminKey(k.ID, ad.signCount)
|
||||
s.limiter.fin(ip, true)
|
||||
if err := s.setSession(w, k.AdminID); err != nil {
|
||||
writeJSON(w, map[string]any{"ok": false, "error": "внутренняя ошибка"})
|
||||
return
|
||||
}
|
||||
s.db.Audit(k.AdminID, "", "login_passkey:"+k.Name)
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// POST /settings/admins/keys/reset - drop all passkeys of an admin, who signs
|
||||
// in with the password again: for one who lost theirs.
|
||||
func (s *Server) handleResetAdminKeys(w http.ResponseWriter, r *http.Request) {
|
||||
if !checkCSRF(r) {
|
||||
http.Error(w, "bad csrf", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
id, _ := strconv.ParseInt(r.FormValue("id"), 10, 64)
|
||||
_ = s.db.DeleteAdminKeys(id)
|
||||
if a := s.currentAdmin(r); a != nil {
|
||||
s.db.Audit(a.ID, "", fmt.Sprintf("passkeys_reset:%d", id))
|
||||
}
|
||||
http.Redirect(w, r, "/settings", http.StatusSeeOther)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// The PIN: four digits an admin can set, asked for by the page in every new
|
||||
// browser tab (a reload of the same tab does not ask again). The server
|
||||
// checks it; five wrong ones in a row end the session.
|
||||
|
||||
var pinRe = regexp.MustCompile(`^[0-9]{4}$`)
|
||||
|
||||
// pinMisses counts wrong PINs per session.
|
||||
type pinMisses struct {
|
||||
mu sync.Mutex
|
||||
m map[string]int
|
||||
}
|
||||
|
||||
func (p *pinMisses) miss(sid string) int {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.m == nil {
|
||||
p.m = map[string]int{}
|
||||
}
|
||||
p.m[sid]++
|
||||
return p.m[sid]
|
||||
}
|
||||
|
||||
func (p *pinMisses) clear(sid string) {
|
||||
p.mu.Lock()
|
||||
delete(p.m, sid)
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
// GET /api/panel/pin - whether the admin has a PIN to ask for.
|
||||
func (s *Server) handlePinState(w http.ResponseWriter, r *http.Request) {
|
||||
a := s.currentAdmin(r)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
writeJSON(w, map[string]any{"pin": s.db.AdminPin(a.ID) != ""})
|
||||
}
|
||||
|
||||
// POST /api/panel/pin/check {"pin"} - the PIN typed in a new tab.
|
||||
func (s *Server) handlePinCheck(w http.ResponseWriter, r *http.Request) {
|
||||
a := s.currentAdmin(r)
|
||||
var in struct {
|
||||
Pin string `json:"pin"`
|
||||
}
|
||||
_ = json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<10)).Decode(&in)
|
||||
h := s.db.AdminPin(a.ID)
|
||||
c, _ := r.Cookie(sessionCookie)
|
||||
if h == "" || (pinRe.MatchString(in.Pin) && checkPassword(h, in.Pin)) {
|
||||
s.pinMisses.clear(c.Value)
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
return
|
||||
}
|
||||
if n := s.pinMisses.miss(c.Value); n >= 5 {
|
||||
s.pinMisses.clear(c.Value)
|
||||
s.clearSession(w, r)
|
||||
s.db.Audit(a.ID, "", "pin_lockout")
|
||||
writeJSON(w, map[string]any{"ok": false, "out": true, "error": "слишком много неверных попыток"})
|
||||
return
|
||||
} else {
|
||||
writeJSON(w, map[string]any{"ok": false, "left": 5 - n, "error": "неверный PIN"})
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/panel/account/pin {"pin", "current"} - set or change the PIN;
|
||||
// with pin empty, remove it. A PIN in place is needed to change or drop it.
|
||||
func (s *Server) handlePinSet(w http.ResponseWriter, r *http.Request) {
|
||||
a := s.currentAdmin(r)
|
||||
var in struct {
|
||||
Pin string `json:"pin"`
|
||||
Current string `json:"current"`
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<10)).Decode(&in); err != nil {
|
||||
writeJSON(w, map[string]any{"ok": false, "error": "плохой запрос"})
|
||||
return
|
||||
}
|
||||
if h := s.db.AdminPin(a.ID); h != "" && !checkPassword(h, in.Current) {
|
||||
writeJSON(w, map[string]any{"ok": false, "error": "текущий PIN не тот"})
|
||||
return
|
||||
}
|
||||
hash := ""
|
||||
if in.Pin != "" {
|
||||
if !pinRe.MatchString(in.Pin) {
|
||||
writeJSON(w, map[string]any{"ok": false, "error": "PIN - ровно 4 цифры"})
|
||||
return
|
||||
}
|
||||
var err error
|
||||
if hash, err = hashPassword(in.Pin); err != nil {
|
||||
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := s.db.SetAdminPin(a.ID, hash); err != nil {
|
||||
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
action := "pin_set"
|
||||
if hash == "" {
|
||||
action = "pin_remove"
|
||||
}
|
||||
s.db.Audit(a.ID, "", action)
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// Each user's look of the panel (Аккаунт › Вид), kept on the server so it
|
||||
// is the same on every device: where a tab's icons go, the terminal's font
|
||||
// size, a compact file list, the time column, animations, and whether a
|
||||
// terminal or file pane opened «Здесь» starts in the active pane's folder.
|
||||
type viewPrefs struct {
|
||||
TabIcons string `json:"tabIcons,omitempty"` // left (default), right, hide
|
||||
TermFont int `json:"termFont,omitempty"` // 11-20; 0: the default
|
||||
Compact bool `json:"compact,omitempty"` // denser file lists
|
||||
FileTime string `json:"fileTime,omitempty"` // show (default), hide
|
||||
Motion string `json:"motion,omitempty"` // on (default), off
|
||||
OpenHere string `json:"openHere,omitempty"` // on (default), off
|
||||
}
|
||||
|
||||
func prefsKey(id int64) string { return "prefs_" + strconv.FormatInt(id, 10) }
|
||||
|
||||
// clean keeps known values only.
|
||||
func (p viewPrefs) clean() viewPrefs {
|
||||
if p.TabIcons != "right" && p.TabIcons != "hide" {
|
||||
p.TabIcons = ""
|
||||
}
|
||||
if p.TermFont < 11 || p.TermFont > 20 {
|
||||
p.TermFont = 0
|
||||
}
|
||||
if p.FileTime != "hide" {
|
||||
p.FileTime = ""
|
||||
}
|
||||
if p.Motion != "off" {
|
||||
p.Motion = ""
|
||||
}
|
||||
if p.OpenHere != "off" {
|
||||
p.OpenHere = ""
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func (s *Server) prefs(id int64) viewPrefs {
|
||||
var p viewPrefs
|
||||
if v, _ := s.db.GetConfig(prefsKey(id)); v != "" {
|
||||
_ = json.Unmarshal([]byte(v), &p)
|
||||
}
|
||||
return p.clean()
|
||||
}
|
||||
|
||||
// GET /api/panel/account/prefs
|
||||
func (s *Server) handlePrefs(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, s.prefs(s.currentAdmin(r).ID))
|
||||
}
|
||||
|
||||
// POST /api/panel/account/prefs - the whole set.
|
||||
func (s *Server) handlePrefsSet(w http.ResponseWriter, r *http.Request) {
|
||||
var p viewPrefs
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&p); err != nil {
|
||||
writeJSON(w, map[string]any{"ok": false, "error": "плохой запрос"})
|
||||
return
|
||||
}
|
||||
b, _ := json.Marshal(p.clean())
|
||||
_ = s.db.SetConfig(prefsKey(s.currentAdmin(r).ID), string(b))
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"html"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Web search for the assistant, when the user turned «Поиск» on: DuckDuckGo's
|
||||
// plain pages, no key and nothing to install. The light page first, the
|
||||
// HTML one if that fails; DuckDuckGo turns away too many requests from one
|
||||
// address, and then the assistant is told so.
|
||||
|
||||
type searchResult struct {
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Snippet string `json:"snippet"`
|
||||
}
|
||||
|
||||
var (
|
||||
reLiteLink = regexp.MustCompile(`(?s)<a[^>]+href="([^"]+)"[^>]*class='result-link'[^>]*>(.*?)</a>`)
|
||||
reLiteSnippet = regexp.MustCompile(`(?s)<td class='result-snippet'>(.*?)</td>`)
|
||||
reHTMLLink = regexp.MustCompile(`(?s)<a[^>]+class="result__a"[^>]+href="([^"]+)"[^>]*>(.*?)</a>`)
|
||||
reHTMLSnippet = regexp.MustCompile(`(?s)<a[^>]+class="result__snippet"[^>]*>(.*?)</a>`)
|
||||
reTags = regexp.MustCompile(`<[^>]+>`)
|
||||
)
|
||||
|
||||
// text is a bit of a results page as plain text.
|
||||
func text(s string) string {
|
||||
return strings.Join(strings.Fields(html.UnescapeString(reTags.ReplaceAllString(s, ""))), " ")
|
||||
}
|
||||
|
||||
// realURL takes DuckDuckGo's redirect (/l/?uddg=…) back to the address.
|
||||
func realURL(h string) string {
|
||||
h = html.UnescapeString(h)
|
||||
if strings.HasPrefix(h, "//") {
|
||||
h = "https:" + h
|
||||
}
|
||||
if u, err := url.Parse(h); err == nil {
|
||||
if t := u.Query().Get("uddg"); t != "" {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// parseDDG reads the results of the light or the HTML page.
|
||||
func parseDDG(page string) []searchResult {
|
||||
links, snips := reLiteLink.FindAllStringSubmatch(page, -1), reLiteSnippet.FindAllStringSubmatch(page, -1)
|
||||
if len(links) == 0 {
|
||||
links, snips = reHTMLLink.FindAllStringSubmatch(page, -1), reHTMLSnippet.FindAllStringSubmatch(page, -1)
|
||||
}
|
||||
var out []searchResult
|
||||
for i, l := range links {
|
||||
r := searchResult{Title: text(l[2]), URL: realURL(l[1])}
|
||||
if i < len(snips) {
|
||||
r.Snippet = text(snips[i][1])
|
||||
}
|
||||
// Ads lead back through DuckDuckGo itself: left out.
|
||||
if strings.Contains(r.URL, "duckduckgo.com/y.js") || r.Title == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var searchClient = &http.Client{Timeout: 15 * time.Second}
|
||||
|
||||
// The engine is the admins' choice (Настройки › ИИ-ассистент): DuckDuckGo,
|
||||
// a SearXNG of one's own, Brave Search or Tavily; when the chosen one fails,
|
||||
// DuckDuckGo answers instead.
|
||||
const (
|
||||
cfgSearchEngine = "ai_search"
|
||||
cfgSearchURL = "ai_search_url"
|
||||
cfgSearchKey = "ai_search_key"
|
||||
)
|
||||
|
||||
type searchConf struct{ Engine, URL, Key string }
|
||||
|
||||
func (s *Server) searchConf() searchConf {
|
||||
e, _ := s.db.GetConfig(cfgSearchEngine)
|
||||
u, _ := s.db.GetConfig(cfgSearchURL)
|
||||
k, _ := s.db.GetConfig(cfgSearchKey)
|
||||
return searchConf{Engine: e, URL: strings.TrimRight(u, "/"), Key: k}
|
||||
}
|
||||
|
||||
var (
|
||||
braveURL = "https://api.search.brave.com/res/v1/web/search"
|
||||
tavilyURL = "https://api.tavily.com/search"
|
||||
ddgSearch = webSearch
|
||||
)
|
||||
|
||||
// engineName is how the engine is called to people and to the model.
|
||||
func engineName(e string) string {
|
||||
switch e {
|
||||
case "searxng":
|
||||
return "SearXNG"
|
||||
case "brave":
|
||||
return "Brave Search"
|
||||
case "tavily":
|
||||
return "Tavily"
|
||||
}
|
||||
return "DuckDuckGo"
|
||||
}
|
||||
|
||||
// searchWith asks the engine c names; DuckDuckGo if that one fails. It
|
||||
// tells which engine the results came from (and why, when it was not the
|
||||
// chosen one).
|
||||
func searchWith(ctx context.Context, c searchConf, q string, n int) ([]searchResult, string, error) {
|
||||
var rs []searchResult
|
||||
var err error
|
||||
switch c.Engine {
|
||||
case "searxng":
|
||||
rs, err = searchSearx(ctx, c, q)
|
||||
case "brave":
|
||||
rs, err = searchBrave(ctx, c, q, n)
|
||||
case "tavily":
|
||||
rs, err = searchTavily(ctx, c, q, n)
|
||||
default:
|
||||
rs, err = ddgSearch(ctx, q, n)
|
||||
return rs, "DuckDuckGo", err
|
||||
}
|
||||
if err != nil || len(rs) == 0 {
|
||||
why := "ничего не нашёл"
|
||||
if err != nil {
|
||||
why = err.Error()
|
||||
}
|
||||
log.Printf("search: %s failed (%s), DuckDuckGo instead", engineName(c.Engine), why)
|
||||
rs, err = ddgSearch(ctx, q, n)
|
||||
return rs, "DuckDuckGo (" + engineName(c.Engine) + " не ответил: " + why + ")", err
|
||||
}
|
||||
if len(rs) > n {
|
||||
rs = rs[:n]
|
||||
}
|
||||
return rs, engineName(c.Engine), nil
|
||||
}
|
||||
|
||||
// searchPrompt tells the model it can search, and with what.
|
||||
func searchPrompt(c searchConf) string {
|
||||
return "\nПоиск в интернете включён (поисковик: " + engineName(c.Engine) + "): блок ```search\nзапрос\n``` - панель найдёт и пришлёт результаты " +
|
||||
"(заголовок, адрес, отрывок) следующим сообщением, с пометкой, через какой поисковик они пришли.\n" +
|
||||
"Ищи активно: свежие данные, версии, документация, тексты ошибок, всё, в чём не уверен, - сначала поиск, потом ответ со ссылками на найденное. " +
|
||||
"Чтобы прочитать найденную страницу целиком (или адрес, который дал пользователь), - блок ```fetch\nhttps://адрес\n```: придёт её текст. " +
|
||||
"Одно действие за ответ. Ссылки не выдумывай."
|
||||
}
|
||||
|
||||
// getJSON fetches url (with the headers) into v.
|
||||
func getJSON(ctx context.Context, method, url string, body io.Reader, hdr map[string]string, v any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, method, url, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
for k, val := range hdr {
|
||||
req.Header.Set(k, val)
|
||||
}
|
||||
resp, err := searchClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return errors.New("поисковик ответил " + resp.Status)
|
||||
}
|
||||
return json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(v)
|
||||
}
|
||||
|
||||
// searchSearx: a SearXNG with JSON output on (search.formats: json).
|
||||
func searchSearx(ctx context.Context, c searchConf, q string) ([]searchResult, error) {
|
||||
if c.URL == "" {
|
||||
return nil, errors.New("не задан адрес SearXNG")
|
||||
}
|
||||
var out struct {
|
||||
Results []struct{ Title, URL, Content string } `json:"results"`
|
||||
}
|
||||
if err := getJSON(ctx, http.MethodGet, c.URL+"/search?format=json&q="+url.QueryEscape(q), nil, nil, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var rs []searchResult
|
||||
for _, r := range out.Results {
|
||||
rs = append(rs, searchResult{Title: r.Title, URL: r.URL, Snippet: text(r.Content)})
|
||||
}
|
||||
return rs, nil
|
||||
}
|
||||
|
||||
func searchBrave(ctx context.Context, c searchConf, q string, n int) ([]searchResult, error) {
|
||||
var out struct {
|
||||
Web struct {
|
||||
Results []struct{ Title, URL, Description string } `json:"results"`
|
||||
} `json:"web"`
|
||||
}
|
||||
err := getJSON(ctx, http.MethodGet, braveURL+"?count="+strconv.Itoa(n)+"&q="+url.QueryEscape(q), nil,
|
||||
map[string]string{"X-Subscription-Token": c.Key}, &out)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var rs []searchResult
|
||||
for _, r := range out.Web.Results {
|
||||
rs = append(rs, searchResult{Title: text(r.Title), URL: r.URL, Snippet: text(r.Description)})
|
||||
}
|
||||
return rs, nil
|
||||
}
|
||||
|
||||
func searchTavily(ctx context.Context, c searchConf, q string, n int) ([]searchResult, error) {
|
||||
b, _ := json.Marshal(map[string]any{"query": q, "max_results": n})
|
||||
var out struct {
|
||||
Results []struct{ Title, URL, Content string } `json:"results"`
|
||||
}
|
||||
err := getJSON(ctx, http.MethodPost, tavilyURL, strings.NewReader(string(b)),
|
||||
map[string]string{"Authorization": "Bearer " + c.Key, "Content-Type": "application/json"}, &out)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var rs []searchResult
|
||||
for _, r := range out.Results {
|
||||
rs = append(rs, searchResult{Title: r.Title, URL: r.URL, Snippet: r.Content})
|
||||
}
|
||||
return rs, nil
|
||||
}
|
||||
|
||||
// webSearch asks DuckDuckGo for q: up to n results.
|
||||
func webSearch(ctx context.Context, q string, n int) ([]searchResult, error) {
|
||||
var last error
|
||||
for _, base := range []string{"https://lite.duckduckgo.com/lite/?q=", "https://html.duckduckgo.com/html/?q="} {
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, base+url.QueryEscape(q), nil)
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36")
|
||||
req.Header.Set("Accept-Language", "ru,en;q=0.8")
|
||||
resp, err := searchClient.Do(req)
|
||||
if err != nil {
|
||||
last = err
|
||||
continue
|
||||
}
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
|
||||
resp.Body.Close()
|
||||
rs := parseDDG(string(b))
|
||||
if resp.StatusCode == http.StatusOK && len(rs) > 0 {
|
||||
if len(rs) > n {
|
||||
rs = rs[:n]
|
||||
}
|
||||
return rs, nil
|
||||
}
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
last = errors.New("ничего не нашлось")
|
||||
} else {
|
||||
last = errors.New("поисковик не ответил (" + resp.Status + "), возможно, временно ограничил запросы")
|
||||
}
|
||||
}
|
||||
return nil, last
|
||||
}
|
||||
|
||||
// POST /api/panel/ai/search - {q}: the results, for a user with the
|
||||
// assistant on (the «Поиск» switch is the page's: it lets the model ask).
|
||||
func (s *Server) handleAISearch(w http.ResponseWriter, r *http.Request) {
|
||||
a := s.currentAdmin(r)
|
||||
if on, _ := s.db.GetConfig(aiUserKey(a.ID)); on != "1" {
|
||||
writeJSON(w, map[string]any{"ok": false, "error": "ассистент выключен в профиле"})
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
Q string `json:"q"`
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&in); err != nil || strings.TrimSpace(in.Q) == "" {
|
||||
writeJSON(w, map[string]any{"ok": false, "error": "пустой запрос"})
|
||||
return
|
||||
}
|
||||
rs, used, err := searchWith(r.Context(), s.searchConf(), strings.TrimSpace(in.Q), 6)
|
||||
if err != nil {
|
||||
writeJSON(w, map[string]any{"ok": false, "error": err.Error(), "engine": used})
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"ok": true, "data": rs, "engine": used})
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The results of DuckDuckGo's light page: titles, real addresses (not its
|
||||
// redirects), snippets without tags.
|
||||
func TestParseDDGLite(t *testing.T) {
|
||||
b, err := os.ReadFile("testdata/ddg_lite.html")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rs := parseDDG(string(b))
|
||||
if len(rs) < 5 {
|
||||
t.Fatalf("results: %d", len(rs))
|
||||
}
|
||||
r := rs[0]
|
||||
if r.URL != "https://go.dev/doc/database/cancel-operations" || !strings.Contains(r.Title, "Canceling in-progress operations") ||
|
||||
!strings.Contains(r.Snippet, "Learn how to use context.Context") || strings.Contains(r.Snippet, "<b>") {
|
||||
t.Fatalf("first: %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
// Each search engine of the settings, and DuckDuckGo when the chosen one
|
||||
// fails.
|
||||
func TestSearchProviders(t *testing.T) {
|
||||
var gotKey, gotQ string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/search" && r.URL.Query().Get("format") == "json": // SearXNG
|
||||
gotQ = r.URL.Query().Get("q")
|
||||
fmt.Fprint(w, `{"results":[{"title":"SX","url":"https://a.example/","content":"from searx"}]}`)
|
||||
case r.URL.Path == "/res/v1/web/search": // Brave
|
||||
gotKey, gotQ = r.Header.Get("X-Subscription-Token"), r.URL.Query().Get("q")
|
||||
fmt.Fprint(w, `{"web":{"results":[{"title":"BR","url":"https://b.example/","description":"from <strong>brave</strong>"}]}}`)
|
||||
case r.URL.Path == "/search" && r.Method == http.MethodPost: // Tavily
|
||||
var in map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&in)
|
||||
gotKey, gotQ = r.Header.Get("Authorization"), in["query"].(string)
|
||||
fmt.Fprint(w, `{"results":[{"title":"TV","url":"https://t.example/","content":"from tavily"}]}`)
|
||||
default:
|
||||
http.Error(w, "no", 500)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
braveURL, tavilyURL = srv.URL+"/res/v1/web/search", srv.URL+"/search"
|
||||
ctx := context.Background()
|
||||
|
||||
rs, used, err := searchWith(ctx, searchConf{Engine: "searxng", URL: srv.URL}, "q1", 6)
|
||||
if err != nil || len(rs) != 1 || rs[0].Title != "SX" || gotQ != "q1" || used != "SearXNG" {
|
||||
t.Fatalf("searxng: %+v %v", rs, err)
|
||||
}
|
||||
rs, _, err = searchWith(ctx, searchConf{Engine: "brave", Key: "bk"}, "q2", 6)
|
||||
if err != nil || rs[0].Snippet != "from brave" || gotKey != "bk" || gotQ != "q2" {
|
||||
t.Fatalf("brave: %+v %v %q", rs, err, gotKey)
|
||||
}
|
||||
rs, used, err = searchWith(ctx, searchConf{Engine: "tavily", Key: "tk"}, "q3", 6)
|
||||
if err != nil || rs[0].Title != "TV" || gotKey != "Bearer tk" || gotQ != "q3" || used != "Tavily" {
|
||||
t.Fatalf("tavily: %+v %v %q", rs, err, gotKey)
|
||||
}
|
||||
// The chosen one fails: DuckDuckGo instead (stood in for here).
|
||||
ddgSearch = func(ctx context.Context, q string, n int) ([]searchResult, error) {
|
||||
return []searchResult{{Title: "DDG"}}, nil
|
||||
}
|
||||
rs, used, err = searchWith(ctx, searchConf{Engine: "searxng", URL: srv.URL + "/broken"}, "q4", 6)
|
||||
if err != nil || rs[0].Title != "DDG" || !strings.Contains(used, "DuckDuckGo") || !strings.Contains(used, "SearXNG не ответил") {
|
||||
t.Fatalf("fallback: %+v %v", rs, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The chosen engine's limit spent (429 and the like): DuckDuckGo answers.
|
||||
func TestSearchLimitFallsBack(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, `{"error":"rate limit"}`, http.StatusTooManyRequests)
|
||||
}))
|
||||
defer srv.Close()
|
||||
old := ddgSearch
|
||||
defer func() { ddgSearch = old }()
|
||||
ddgSearch = func(ctx context.Context, q string, n int) ([]searchResult, error) {
|
||||
return []searchResult{{Title: "DDG " + q}}, nil
|
||||
}
|
||||
braveURL, tavilyURL = srv.URL, srv.URL
|
||||
for _, e := range []string{"brave", "tavily", "searxng"} {
|
||||
rs, _, err := searchWith(context.Background(), searchConf{Engine: e, Key: "k", URL: srv.URL}, "x", 6)
|
||||
if err != nil || len(rs) != 1 || rs[0].Title != "DDG x" {
|
||||
t.Fatalf("%s: %+v %v", e, rs, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The model is told which engine it searches with.
|
||||
func TestSearchPromptNamesEngine(t *testing.T) {
|
||||
if p := searchPrompt(searchConf{Engine: "tavily"}); !strings.Contains(p, "Tavily") || strings.Contains(p, "DuckDuckGo") {
|
||||
t.Fatalf("tavily: %s", p)
|
||||
}
|
||||
if p := searchPrompt(searchConf{}); !strings.Contains(p, "DuckDuckGo") {
|
||||
t.Fatalf("default: %s", p)
|
||||
}
|
||||
}
|
||||
+152
-42
@@ -55,10 +55,16 @@ type Server struct {
|
||||
tunnelMu sync.Mutex // serializes port changes
|
||||
tunnelPort atomic.Int64
|
||||
|
||||
stats statCache // last load sample per agent, for the status page
|
||||
relays speedRelays // speed tests that run through the browser
|
||||
meters speedMeters // byte counts of the speed tests under way
|
||||
xfers xfers // files on their way between a browser and an agent
|
||||
stats statCache // last load sample per agent, for the status page
|
||||
relays speedRelays // speed tests that run through the browser
|
||||
meters speedMeters // byte counts of the speed tests under way
|
||||
xfers xfers // files on their way between a browser and an agent
|
||||
ship shipping // files on their way from one machine to another
|
||||
viaDirectA sync.Map // agent id -> true while it reaches the server at the direct address
|
||||
challenges challenges // passkey challenges handed out
|
||||
wsAgents wsAgents // agents on their WebSocket now
|
||||
pinMisses pinMisses // wrong PINs per session
|
||||
assets map[string]string // static file -> hash of its content
|
||||
}
|
||||
|
||||
// New builds the server, parsing embedded templates.
|
||||
@@ -66,7 +72,15 @@ func New(opts Options) (*Server, error) {
|
||||
if err := opts.DB.ensureDefaults(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tmpl, err := template.ParseFS(webFS, "web/templates/*.html")
|
||||
mainHost = func() string { return directHost(opts.DB.HostAddress()) }
|
||||
staticFS, _ := fs.Sub(webFS, "web/static")
|
||||
assets := assetHashes(staticFS)
|
||||
funcs := template.FuncMap{
|
||||
// asset is a static file's URL with its content hash, so a new
|
||||
// release is a new URL that no browser or proxy has cached.
|
||||
"asset": func(name string) string { return "/static/" + name + "?v=" + assets[name] },
|
||||
}
|
||||
tmpl, err := template.New("").Funcs(funcs).ParseFS(webFS, "web/templates/*.html")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -92,6 +106,7 @@ func New(opts Options) (*Server, error) {
|
||||
distDir: opts.DistDir,
|
||||
secureCookies: opts.SecureCookies,
|
||||
installTmpl: installTmpl,
|
||||
assets: assets,
|
||||
}
|
||||
s.ufw = newUfwManager(opts.DB, s.TunnelPort)
|
||||
// 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
|
||||
// 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 {
|
||||
port := s.TunnelPort()
|
||||
if port == 0 {
|
||||
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
|
||||
@@ -186,7 +207,7 @@ func (s *Server) Handler() http.Handler {
|
||||
|
||||
// Static assets (xterm.js etc.) from the embedded FS.
|
||||
staticFS, _ := fs.Sub(webFS, "web/static")
|
||||
mux.Handle("GET /static/", http.StripPrefix("/static/", staticHandler(staticFS)))
|
||||
mux.Handle("GET /static/", http.StripPrefix("/static/", staticHandler(staticFS, s.assets)))
|
||||
|
||||
// Public: install script + agent binary downloads.
|
||||
mux.HandleFunc("GET /install.sh", s.handleInstallScript)
|
||||
@@ -201,33 +222,70 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("GET /login", s.handleLoginForm)
|
||||
mux.HandleFunc("POST /login", s.handleLoginSubmit)
|
||||
mux.HandleFunc("POST /logout", s.handleLogout)
|
||||
mux.HandleFunc("POST /api/login/key/begin", s.handleKeyLoginBegin)
|
||||
mux.HandleFunc("POST /api/login/key/finish", s.handleKeyLoginFinish)
|
||||
|
||||
// Panel (admin-only).
|
||||
mux.Handle("GET /{$}", s.requireAdmin(s.handleDashboard))
|
||||
mux.Handle("GET /agents/{id}/terminal", s.requireAdmin(s.handleTerminalPage))
|
||||
mux.Handle("GET /agents/{id}/files", s.requireAdmin(s.handleFilesPage))
|
||||
mux.Handle("GET /workspace", s.requireAdmin(s.handleShell))
|
||||
mux.Handle("GET /terminal", s.requireAdmin(s.handleShell)) // the old address
|
||||
mux.Handle("GET /agents/{id}/terminal", s.requireAdmin(s.handleShell))
|
||||
mux.Handle("GET /agents/{id}/{kind}", s.requireAdmin(s.handleShell)) // files
|
||||
mux.Handle("GET /agents/{id}/forwards", s.requireAdmin(s.handleForwardsPage))
|
||||
mux.Handle("GET /agents/{id}/status", s.requireAdmin(s.handleStatusPage))
|
||||
mux.Handle("GET /agents/{id}/edit", s.requireAdmin(s.handleAgentEdit))
|
||||
mux.Handle("POST /agents/{id}/rename", s.requireAdmin(s.handleAgentRename))
|
||||
mux.Handle("POST /agents/{id}/update", s.requireAdmin(s.handleAgentUpdate))
|
||||
mux.Handle("POST /agents/{id}/user", s.requireAdmin(s.handleAgentSetUser))
|
||||
mux.Handle("POST /agents/{id}/transport", s.requireAdmin(s.handleAgentTransport))
|
||||
mux.Handle("POST /agents/{id}/users/add", s.requireAdmin(s.handleAgentAddUser))
|
||||
mux.Handle("POST /agents/{id}/delete", s.requireAdmin(s.handleAgentDelete))
|
||||
mux.Handle("GET /settings", s.requireAdmin(s.handleSettings))
|
||||
mux.Handle("POST /settings/host", s.requireAdmin(s.handleSetHost))
|
||||
mux.Handle("POST /settings/tunnel", s.requireAdmin(s.handleSetTunnel))
|
||||
mux.Handle("POST /settings/secret/rotate", s.requireAdmin(s.handleRotateSecret))
|
||||
mux.Handle("POST /settings/admins/add", s.requireAdmin(s.handleAddAdmin))
|
||||
mux.Handle("POST /settings/admins/delete", s.requireAdmin(s.handleDeleteAdmin))
|
||||
mux.Handle("POST /settings/ufw/toggle", s.requireAdmin(s.handleUfwToggle))
|
||||
mux.Handle("POST /settings/ufw/add", s.requireAdmin(s.handleUfwAdd))
|
||||
mux.Handle("POST /settings/ufw/revoke", s.requireAdmin(s.handleUfwRevoke))
|
||||
mux.Handle("POST /agents/{id}/delete", s.adminsOnly(s.handleAgentDelete))
|
||||
mux.Handle("GET /settings", s.adminsOnly(s.handleSettings))
|
||||
mux.Handle("POST /settings/host", s.adminsOnly(s.handleSetHost))
|
||||
mux.Handle("POST /settings/tunnel", s.adminsOnly(s.handleSetTunnel))
|
||||
mux.Handle("POST /settings/secret/rotate", s.adminsOnly(s.handleRotateSecret))
|
||||
mux.Handle("POST /settings/install/code", s.adminsOnly(s.handleInstallCode))
|
||||
mux.Handle("POST /settings/admins/add", s.adminsOnly(s.handleAddAdmin))
|
||||
mux.Handle("POST /settings/admins/delete", s.adminsOnly(s.handleDeleteAdmin))
|
||||
mux.Handle("GET /api/panel/users/{id}/agents", s.adminsOnly(s.handleUserAgents))
|
||||
mux.Handle("POST /api/panel/users/{id}", s.adminsOnly(s.handleUserEdit))
|
||||
mux.Handle("POST /settings/admins/keys/reset", s.adminsOnly(s.handleResetAdminKeys))
|
||||
mux.Handle("GET /api/panel/account", s.requireAdmin(s.handleAccount))
|
||||
mux.Handle("GET /api/panel/account/prefs", s.requireAdmin(s.handlePrefs))
|
||||
mux.Handle("POST /api/panel/account/prefs", s.requireAdmin(s.handlePrefsSet))
|
||||
mux.Handle("GET /api/panel/ai", s.requireAdmin(s.handleAIState))
|
||||
mux.Handle("GET /api/panel/ssh/hosts", s.requireAdmin(s.handleSSHHosts))
|
||||
mux.Handle("POST /api/panel/ssh/hosts", s.requireAdmin(s.handleSSHHostAdd))
|
||||
mux.Handle("POST /api/panel/ssh/hosts/{hid}/delete", s.requireAdmin(s.handleSSHHostDelete))
|
||||
mux.Handle("POST /api/panel/ai/toggle", s.requireAdmin(s.handleAIToggle))
|
||||
mux.Handle("POST /api/panel/ai/chat", s.requireAdmin(s.handleAIChat))
|
||||
mux.Handle("GET /api/panel/ai/chats", s.requireAdmin(s.handleAIChats))
|
||||
mux.Handle("POST /api/panel/ai/chats", s.requireAdmin(s.handleAIChatNew))
|
||||
mux.Handle("GET /api/panel/ai/chats/{cid}", s.requireAdmin(s.handleAIHistory))
|
||||
mux.Handle("POST /api/panel/ai/chats/{cid}/delete", s.requireAdmin(s.handleAIChatDelete))
|
||||
mux.Handle("POST /api/panel/ai/note", s.requireAdmin(s.handleAINote))
|
||||
mux.Handle("POST /api/panel/ai/search", s.requireAdmin(s.handleAISearch))
|
||||
mux.Handle("POST /api/panel/ai/fetch", s.requireAdmin(s.handleAIFetch))
|
||||
mux.Handle("POST /settings/ai", s.adminsOnly(s.handleSetAI))
|
||||
mux.Handle("POST /settings/db", s.adminsOnly(s.handleSetDB))
|
||||
mux.Handle("POST /settings/db/restart", s.adminsOnly(s.handleRestart))
|
||||
mux.Handle("POST /api/panel/account/keys/begin", s.requireAdmin(s.handleKeyRegisterBegin))
|
||||
mux.Handle("POST /api/panel/account/keys/finish", s.requireAdmin(s.handleKeyRegisterFinish))
|
||||
mux.Handle("POST /api/panel/account/keys/{id}/delete", s.requireAdmin(s.handleKeyDelete))
|
||||
mux.Handle("POST /api/panel/account/password", s.requireAdmin(s.handlePasswordToggle))
|
||||
mux.Handle("POST /api/panel/account/pin", s.requireAdmin(s.handlePinSet))
|
||||
mux.Handle("GET /api/panel/pin", s.requireAdmin(s.handlePinState))
|
||||
mux.Handle("POST /api/panel/pin/check", s.requireAdmin(s.handlePinCheck))
|
||||
mux.Handle("POST /api/panel/account/password/change", s.requireAdmin(s.handlePasswordChange))
|
||||
mux.Handle("POST /settings/ufw/toggle", s.adminsOnly(s.handleUfwToggle))
|
||||
mux.Handle("POST /settings/ufw/add", s.adminsOnly(s.handleUfwAdd))
|
||||
mux.Handle("POST /settings/ufw/revoke", s.adminsOnly(s.handleUfwRevoke))
|
||||
|
||||
// Panel terminal over HTTP long-polling (admin-only).
|
||||
mux.Handle("POST /api/panel/term/{agentID}/open", s.requireAdmin(s.handlePanelOpen))
|
||||
mux.Handle("GET /api/panel/term/{sid}/out", s.requireAdmin(s.handlePanelOut))
|
||||
mux.Handle("POST /api/panel/term/{sid}/in", s.requireAdmin(s.handlePanelIn))
|
||||
mux.Handle("GET /api/panel/term/{sid}/ws", s.requireAdmin(s.handlePanelTermWS))
|
||||
mux.Handle("POST /api/panel/term/{sid}/close", s.requireAdmin(s.handlePanelClose))
|
||||
|
||||
// Panel file manager (admin-only).
|
||||
@@ -236,7 +294,14 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.Handle("PUT /api/panel/fs/xfer/{xid}/{i}", s.requireAdmin(s.handleXferPut))
|
||||
mux.Handle("POST /api/panel/fs/xfer/{xid}/cancel", s.requireAdmin(s.handleXferCancel))
|
||||
mux.Handle("GET /api/panel/fs/xfer/{xid}/result", s.requireAdmin(s.handleXferResult))
|
||||
mux.Handle("POST /api/panel/agents/{id}/fs/ship", s.requireAdmin(s.handleFSShip))
|
||||
mux.Handle("GET /api/panel/fs/job/{jid}", s.requireAdmin(s.handleShipJob))
|
||||
mux.Handle("POST /api/panel/fs/job/{jid}/cancel", s.requireAdmin(s.handleShipCancel))
|
||||
mux.Handle("POST /api/panel/agents/{id}/fs/{op}", s.requireAdmin(s.handleFSOp))
|
||||
mux.Handle("POST /api/panel/agents/{id}/sshfs/open", s.requireAdmin(s.handleSSHFSOpen))
|
||||
mux.Handle("GET /api/panel/agents/{id}/sshfs/{conn}/download", s.requireAdmin(s.handleSSHFSDownload))
|
||||
mux.Handle("POST /api/panel/agents/{id}/sshfs/{conn}/upload", s.requireAdmin(s.handleSSHFSUpload))
|
||||
mux.Handle("POST /api/panel/agents/{id}/sshfs/{conn}/{op}", s.requireAdmin(s.handleSSHFSOp))
|
||||
|
||||
// Machine status (admin).
|
||||
mux.Handle("GET /api/panel/agents/presence", s.requireAdmin(s.handlePresence))
|
||||
@@ -255,6 +320,7 @@ func (s *Server) Handler() http.Handler {
|
||||
// Agent API (token/secret auth inside handlers).
|
||||
mux.HandleFunc("POST /api/agent/enroll", s.handleEnroll)
|
||||
mux.HandleFunc("GET /api/agent/poll", s.handlePoll)
|
||||
mux.HandleFunc("GET /api/agent/ws", s.handleAgentWS)
|
||||
mux.HandleFunc("GET /api/agent/config", s.handleAgentConfig)
|
||||
mux.HandleFunc("GET /api/agent/term/{sid}/in", s.handleTermIn)
|
||||
mux.HandleFunc("POST /api/agent/term/{sid}/out", s.handleTermOut)
|
||||
@@ -266,6 +332,9 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("POST /api/agent/speed/relay/up", s.handleSpeedRelayUp)
|
||||
mux.HandleFunc("GET /api/agent/fs/xfer/{xid}/{i}", s.handleAgentXferGet)
|
||||
mux.HandleFunc("POST /api/agent/fs/xfer/{xid}/{i}", s.handleAgentXferPost)
|
||||
mux.HandleFunc("POST /api/agent/fs/job/{jid}/progress", s.handleShipProgress)
|
||||
mux.HandleFunc("POST /api/agent/fs/relay/{rid}/{i}", s.handleRelayPost)
|
||||
mux.HandleFunc("GET /api/agent/fs/relay/{rid}/{i}", s.handleRelayGet)
|
||||
|
||||
return s.logRequests(mux)
|
||||
}
|
||||
@@ -282,6 +351,36 @@ func (s *Server) requireAdmin(h http.HandlerFunc) http.Handler {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
// A user who is not an admin uses only the machines given to them:
|
||||
// any other is as if it were not there.
|
||||
if !admin.IsAdmin() {
|
||||
p := r.URL.Path
|
||||
if strings.HasPrefix(p, "/agents/") || strings.HasPrefix(p, "/api/panel/agents/") || strings.HasPrefix(p, "/api/panel/term/") {
|
||||
id := r.PathValue("id")
|
||||
if id == "" {
|
||||
id = r.PathValue("agentID")
|
||||
}
|
||||
if id != "" && id != "presence" && !s.db.CanUseAgent(admin, id) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
h(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// adminsOnly lets only admins through: the settings, users, the firewall.
|
||||
func (s *Server) adminsOnly(h http.HandlerFunc) http.Handler {
|
||||
return s.requireAdmin(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.currentAdmin(r).IsAdmin() {
|
||||
if r.Method == http.MethodGet && !strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Error(w, "только для администраторов", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
h(w, r)
|
||||
})
|
||||
}
|
||||
@@ -336,32 +435,43 @@ func clientIP(r *http.Request) string {
|
||||
return host
|
||||
}
|
||||
|
||||
// staticHandler serves the embedded assets with a content hash as ETag. The
|
||||
// browser asks each time whether a file changed (no-cache) and gets a 304
|
||||
// when it did not, so a new release reaches it at once without reloading
|
||||
// what stayed the same.
|
||||
func staticHandler(fsys fs.FS) http.Handler {
|
||||
// assetHashes hashes every static file, for asset URLs and ETags.
|
||||
func assetHashes(fsys fs.FS) map[string]string {
|
||||
out := map[string]string{}
|
||||
_ = fs.WalkDir(fsys, ".", func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if data, err := fs.ReadFile(fsys, p); err == nil {
|
||||
sum := sha256.Sum256(data)
|
||||
out[p] = hex.EncodeToString(sum[:8])
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// staticHandler serves the embedded assets. Pages ask for them by URLs that
|
||||
// carry the content hash (?v=): such a file never changes and is kept for a
|
||||
// year, by the browser and by a proxy in front such as Cloudflare. Asked for
|
||||
// without it, a file is checked every time against its ETag.
|
||||
func staticHandler(fsys fs.FS, hashes map[string]string) http.Handler {
|
||||
files := http.FileServer(http.FS(fsys))
|
||||
var mu sync.Mutex
|
||||
etags := map[string]string{}
|
||||
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)
|
||||
tag = `"` + hex.EncodeToString(sum[:8]) + `"`
|
||||
}
|
||||
mu.Lock()
|
||||
etags[name] = tag
|
||||
mu.Unlock()
|
||||
// The JS tests sit beside the scripts but are not served.
|
||||
if strings.HasSuffix(r.URL.Path, ".test.js") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if tag != "" {
|
||||
w.Header().Set("ETag", tag)
|
||||
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)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"path"
|
||||
"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"`
|
||||
// FromConn, ToConn: that side is an SSH host held by its agent for a
|
||||
// file pane (its connection id); To is still the agent holding ToConn.
|
||||
FromConn string `json:"from_conn"`
|
||||
ToConn string `json:"to_conn"`
|
||||
// More: other things of Src's folder sent along, under their own names.
|
||||
More []string `json:"more"`
|
||||
}
|
||||
|
||||
// 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 err := s.fillSaved(s.currentAdmin(r).ID, q.SSH); err != nil {
|
||||
writeJSON(w, proto.TaskResult{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
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 == "" && q.ToConn != "" {
|
||||
q.To = from
|
||||
}
|
||||
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 {
|
||||
if len(q.More) > 0 && !s.shipsSeveral(from, q) {
|
||||
// One at a time: an SSH address, or an agent from before 0.2.0.
|
||||
var last proto.TaskResult
|
||||
done, skipped := 0, 0
|
||||
for i, p := range append([]string{q.Src}, q.More...) {
|
||||
one := q
|
||||
one.Src, one.More = p, nil
|
||||
if i > 0 {
|
||||
one.Name = path.Base(path.Clean(p))
|
||||
}
|
||||
if last = s.ship1(ctx, j, from, one); !last.OK {
|
||||
return last
|
||||
}
|
||||
var d struct{ Done, Skipped int }
|
||||
_ = json.Unmarshal(last.Data, &d)
|
||||
done, skipped = done+d.Done, skipped+d.Skipped
|
||||
}
|
||||
data, _ := json.Marshal(map[string]int{"done": done, "skipped": skipped})
|
||||
return proto.TaskResult{OK: true, Data: data}
|
||||
}
|
||||
args := proto.FSShipArgs{Src: q.Src, Name: q.Name, Dir: q.Dir, Conflict: q.Conflict, Job: j.id, More: q.More}
|
||||
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
|
||||
inArgs := args
|
||||
args.Conn, inArgs.Conn = q.FromConn, q.ToConn
|
||||
|
||||
var in proto.TaskResult
|
||||
inDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(inDone)
|
||||
res, err := s.waitTask(ctx, q.To, proto.TaskFSShipIn, inArgs, 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 {
|
||||
for _, p := range append([]string{q.Src}, q.More...) {
|
||||
task, del := proto.TaskFSDelTree, any(proto.FSPathArgs{Path: p})
|
||||
if q.FromConn != "" {
|
||||
raw, _ := json.Marshal(proto.FSPathArgs{Path: p})
|
||||
task, del = proto.TaskSSHFSOp, proto.SSHFSArgs{Conn: q.FromConn, Op: "deltree", Args: raw}
|
||||
}
|
||||
res, err := s.waitTask(ctx, from, task, del, 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)
|
||||
}
|
||||
|
||||
// shipsSeveral: both agents take several things in one stream (0.2.0 on).
|
||||
func (s *Server) shipsSeveral(from string, q shipReq) bool {
|
||||
if q.SSH != nil {
|
||||
return false
|
||||
}
|
||||
for _, id := range []string{from, q.To} {
|
||||
a, _ := s.db.GetAgent(id)
|
||||
if a == nil || !versionAtLeast(a.Version, "0.2.0") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// versionAtLeast: version v (dotted numbers) is min or newer; anything else
|
||||
// is taken for older.
|
||||
func versionAtLeast(v, min string) bool {
|
||||
a, b := strings.Split(v, "."), strings.Split(min, ".")
|
||||
for i := range b {
|
||||
var x, y int
|
||||
if i < len(a) {
|
||||
n, err := strconv.Atoi(a[i])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
x = n
|
||||
}
|
||||
y, _ = strconv.Atoi(b[i])
|
||||
if x != y {
|
||||
return x > y
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||
)
|
||||
|
||||
// Files on an SSH host through a machine, for the panel's file panes: the
|
||||
// machine's agent keeps the SSH connection (open gives its id) and runs the
|
||||
// operations there. Downloads and uploads go in the same pieces as a
|
||||
// machine's own files.
|
||||
|
||||
// POST /api/panel/agents/{id}/sshfs/open - {"ssh": {...}} → {conn, home}.
|
||||
func (s *Server) handleSSHFSOpen(w http.ResponseWriter, r *http.Request) {
|
||||
agentID := r.PathValue("id")
|
||||
var in struct {
|
||||
SSH *proto.SSHTarget `json:"ssh"`
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&in); err != nil || in.SSH == nil {
|
||||
writeJSON(w, proto.TaskResult{Error: "не указан хост"})
|
||||
return
|
||||
}
|
||||
if err := s.fillSaved(s.currentAdmin(r).ID, in.SSH); err != nil {
|
||||
writeJSON(w, proto.TaskResult{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
t := in.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
|
||||
}
|
||||
if !s.fsAgent(w, agentID, true) {
|
||||
return
|
||||
}
|
||||
res, err := s.waitTask(r.Context(), agentID, proto.TaskSSHFSOpen, proto.SSHFSArgs{SSH: t}, fsTaskTimeout)
|
||||
if err != nil {
|
||||
res = proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
if unknownOp(res) {
|
||||
res = proto.TaskResult{Error: "агент не умеет файлы по SSH - обновите его"}
|
||||
}
|
||||
if res.OK {
|
||||
if admin := s.currentAdmin(r); admin != nil {
|
||||
s.db.Audit(admin.ID, agentID, "sshfs.open:"+t.User+"@"+t.Host)
|
||||
}
|
||||
}
|
||||
writeJSON(w, res)
|
||||
}
|
||||
|
||||
// sshfsChanges are the operations that change files, for the audit log.
|
||||
var sshfsChanges = map[string]bool{"write": true, "mkdir": true, "delete": true, "deltree": true, "move": true, "copy": true, "chmod": true, "zip": true, "unzip": true}
|
||||
|
||||
// POST /api/panel/agents/{id}/sshfs/{conn}/{op} - an operation on the host;
|
||||
// the body is in the shape of the machine's own fs.{op}. "close" drops the
|
||||
// connection.
|
||||
func (s *Server) handleSSHFSOp(w http.ResponseWriter, r *http.Request) {
|
||||
agentID, conn, op := r.PathValue("id"), r.PathValue("conn"), r.PathValue("op")
|
||||
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 64<<20))
|
||||
if err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !s.fsAgent(w, agentID, true) {
|
||||
return
|
||||
}
|
||||
task, args := proto.TaskSSHFSOp, proto.SSHFSArgs{Conn: conn, Op: op, Args: json.RawMessage(body)}
|
||||
if op == "close" {
|
||||
task, args = proto.TaskSSHFSClose, proto.SSHFSArgs{Conn: conn}
|
||||
}
|
||||
if len(args.Args) == 0 {
|
||||
args.Args = json.RawMessage("{}")
|
||||
}
|
||||
if sshfsChanges[op] {
|
||||
var d struct{ Path, Src, Dst string }
|
||||
_ = json.Unmarshal(body, &d)
|
||||
if admin := s.currentAdmin(r); admin != nil {
|
||||
s.db.Audit(admin.ID, agentID, "sshfs."+op+":"+strings.TrimSpace(d.Path+" "+d.Src+" "+d.Dst))
|
||||
}
|
||||
}
|
||||
res, err := s.waitTask(r.Context(), agentID, task, args, fsTaskTimeout)
|
||||
if err != nil {
|
||||
res = proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
if unknownOp(res) && op != "close" {
|
||||
res = proto.TaskResult{Error: "агент не умеет файлы по SSH - обновите его"}
|
||||
}
|
||||
writeJSON(w, res)
|
||||
}
|
||||
|
||||
// GET /api/panel/agents/{id}/sshfs/{conn}/download?path=...[&inline=1]
|
||||
func (s *Server) handleSSHFSDownload(w http.ResponseWriter, r *http.Request) {
|
||||
s.download(w, r, proto.TaskSSHFSSend, r.PathValue("conn"))
|
||||
}
|
||||
|
||||
// POST /api/panel/agents/{id}/sshfs/{conn}/upload?path=...&size=N
|
||||
func (s *Server) handleSSHFSUpload(w http.ResponseWriter, r *http.Request) {
|
||||
if admin := s.currentAdmin(r); admin != nil {
|
||||
s.db.Audit(admin.ID, r.PathValue("id"), "sshfs.upload:"+r.URL.Query().Get("path"))
|
||||
}
|
||||
s.uploadStart(w, r, proto.TaskSSHFSPut, r.PathValue("conn"))
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||
)
|
||||
|
||||
// Saved SSH hosts: each user keeps their own list of hosts with the way in -
|
||||
// password or key - so the SSH dialog connects with one choice. The secret is
|
||||
// sealed with AES-256-GCM under the server's key, which lives apart from the
|
||||
// database (LPRA_SECRET_KEY, or a file beside the database made on first
|
||||
// use), and bound to its owner: a row read out of the database, or moved to
|
||||
// another user, opens to nobody.
|
||||
|
||||
type savedHost struct {
|
||||
ID int64 `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
User string `json:"user"`
|
||||
Auth string `json:"auth"` // password, key or agent
|
||||
}
|
||||
|
||||
type sshSecret struct {
|
||||
Password string `json:"password,omitempty"`
|
||||
Key string `json:"key,omitempty"`
|
||||
Passphrase string `json:"passphrase,omitempty"`
|
||||
}
|
||||
|
||||
var (
|
||||
secretKeyOnce sync.Once
|
||||
secretKey []byte
|
||||
secretKeyErr error
|
||||
)
|
||||
|
||||
// sealKey is the server's key for saved secrets.
|
||||
func (s *Server) sealKey() ([]byte, error) {
|
||||
secretKeyOnce.Do(func() {
|
||||
if v := strings.TrimSpace(os.Getenv("LPRA_SECRET_KEY")); v != "" {
|
||||
k, err := hex.DecodeString(v)
|
||||
if err != nil || len(k) != 32 {
|
||||
k, err = base64.StdEncoding.DecodeString(v)
|
||||
}
|
||||
if err != nil || len(k) != 32 {
|
||||
secretKeyErr = errors.New("LPRA_SECRET_KEY: нужны 32 байта (hex или base64)")
|
||||
return
|
||||
}
|
||||
secretKey = k
|
||||
return
|
||||
}
|
||||
path := s.db.path + ".key"
|
||||
if b, err := os.ReadFile(path); err == nil {
|
||||
k, err := hex.DecodeString(strings.TrimSpace(string(b)))
|
||||
if err != nil || len(k) != 32 {
|
||||
secretKeyErr = errors.New(path + ": испорчен ключ")
|
||||
return
|
||||
}
|
||||
secretKey = k
|
||||
return
|
||||
}
|
||||
k := make([]byte, 32)
|
||||
_, _ = rand.Read(k)
|
||||
if err := os.WriteFile(path, []byte(hex.EncodeToString(k)+"\n"), 0o600); err != nil {
|
||||
secretKeyErr = err
|
||||
return
|
||||
}
|
||||
secretKey = k
|
||||
})
|
||||
return secretKey, secretKeyErr
|
||||
}
|
||||
|
||||
func (s *Server) seal(owner int64, plain []byte) (string, error) {
|
||||
k, err := s.sealKey()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
blk, _ := aes.NewCipher(k)
|
||||
g, _ := cipher.NewGCM(blk)
|
||||
nonce := make([]byte, g.NonceSize())
|
||||
_, _ = rand.Read(nonce)
|
||||
out := g.Seal(nonce, nonce, plain, []byte("ssh:"+strconv.FormatInt(owner, 10)))
|
||||
return base64.StdEncoding.EncodeToString(out), nil
|
||||
}
|
||||
|
||||
func (s *Server) open(owner int64, sealed string) ([]byte, error) {
|
||||
k, err := s.sealKey()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b, err := base64.StdEncoding.DecodeString(sealed)
|
||||
blk, _ := aes.NewCipher(k)
|
||||
g, _ := cipher.NewGCM(blk)
|
||||
if err != nil || len(b) < g.NonceSize() {
|
||||
return nil, errors.New("испорчена запись")
|
||||
}
|
||||
return g.Open(nil, b[:g.NonceSize()], b[g.NonceSize():], []byte("ssh:"+strconv.FormatInt(owner, 10)))
|
||||
}
|
||||
|
||||
func (s *Server) saveSSHHost(owner int64, h savedHost, sec sshSecret) (int64, error) {
|
||||
plain, _ := json.Marshal(sec)
|
||||
sealed, err := s.seal(owner, plain)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return s.db.sql.insert(`INSERT INTO ssh_hosts (admin_id, label, host, port, login, auth, secret) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
owner, h.Label, h.Host, h.Port, h.User, h.Auth, sealed)
|
||||
}
|
||||
|
||||
func (s *Server) listSSHHosts(owner int64) []savedHost {
|
||||
rows, err := s.db.sql.Query(`SELECT id, label, host, port, login, auth FROM ssh_hosts WHERE admin_id = ? ORDER BY label, host`, owner)
|
||||
list := []savedHost{}
|
||||
if err != nil {
|
||||
return list
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var h savedHost
|
||||
if rows.Scan(&h.ID, &h.Label, &h.Host, &h.Port, &h.User, &h.Auth) == nil {
|
||||
list = append(list, h)
|
||||
}
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
// fillSaved puts the saved host t.Saved of owner - address and way in - into
|
||||
// t, which then goes on as if typed in.
|
||||
func (s *Server) fillSaved(owner int64, t *proto.SSHTarget) error {
|
||||
if t == nil || t.Saved == 0 {
|
||||
return nil
|
||||
}
|
||||
var h savedHost
|
||||
var sealed string
|
||||
err := s.db.sql.QueryRow(`SELECT host, port, login, auth, secret FROM ssh_hosts WHERE id = ? AND admin_id = ?`, t.Saved, owner).
|
||||
Scan(&h.Host, &h.Port, &h.User, &h.Auth, &sealed)
|
||||
if err != nil {
|
||||
return errors.New("сохранённый хост не найден")
|
||||
}
|
||||
plain, err := s.open(owner, sealed)
|
||||
if err != nil {
|
||||
return errors.New("сохранённый хост не расшифровался")
|
||||
}
|
||||
var sec sshSecret
|
||||
_ = json.Unmarshal(plain, &sec)
|
||||
*t = proto.SSHTarget{Host: h.Host, Port: h.Port, User: h.User, Password: sec.Password, Key: sec.Key, Passphrase: sec.Passphrase}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GET /api/panel/ssh/hosts - the user's saved hosts, without secrets.
|
||||
func (s *Server) handleSSHHosts(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, s.listSSHHosts(s.currentAdmin(r).ID))
|
||||
}
|
||||
|
||||
// POST /api/panel/ssh/hosts - save a host: {label, host, port, user, auth,
|
||||
// password | key, passphrase}.
|
||||
func (s *Server) handleSSHHostAdd(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
savedHost
|
||||
sshSecret
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&in); err != nil {
|
||||
writeJSON(w, map[string]any{"ok": false, "error": "плохой запрос"})
|
||||
return
|
||||
}
|
||||
h := in.savedHost
|
||||
h.Host, h.User, h.Label = strings.TrimSpace(h.Host), strings.TrimSpace(h.User), strings.TrimSpace(h.Label)
|
||||
if h.Port == 0 {
|
||||
h.Port = 22
|
||||
}
|
||||
if h.Host == "" || h.User == "" || h.Port < 1 || h.Port > 65535 || strings.ContainsAny(h.Host+h.User, " \t\r\n/") {
|
||||
writeJSON(w, map[string]any{"ok": false, "error": "нужны хост, порт и пользователь"})
|
||||
return
|
||||
}
|
||||
if h.Label == "" {
|
||||
h.Label = h.User + "@" + h.Host
|
||||
}
|
||||
sec := in.sshSecret
|
||||
switch h.Auth {
|
||||
case "key":
|
||||
sec.Password = ""
|
||||
case "agent":
|
||||
sec = sshSecret{}
|
||||
default:
|
||||
h.Auth = "password"
|
||||
sec.Key, sec.Passphrase = "", ""
|
||||
}
|
||||
id, err := s.saveSSHHost(s.currentAdmin(r).ID, h, sec)
|
||||
if err != nil {
|
||||
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"ok": true, "id": id})
|
||||
}
|
||||
|
||||
// POST /api/panel/ssh/hosts/{hid}/delete
|
||||
func (s *Server) handleSSHHostDelete(w http.ResponseWriter, r *http.Request) {
|
||||
id, _ := strconv.ParseInt(r.PathValue("hid"), 10, 64)
|
||||
_, _ = s.db.sql.Exec(`DELETE FROM ssh_hosts WHERE id = ? AND admin_id = ?`, id, s.currentAdmin(r).ID)
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||
)
|
||||
|
||||
// A saved host's secret is stored encrypted, and only its owner gets it back.
|
||||
func TestSavedSSHHosts(t *testing.T) {
|
||||
s, _ := newTestServer(t)
|
||||
a, _ := s.db.AdminByUsername("adm")
|
||||
id, err := s.saveSSHHost(a.ID, savedHost{Label: "web", Host: "h", Port: 22, User: "root", Auth: "password"}, sshSecret{Password: "s3cret-pass"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var raw string
|
||||
_ = s.db.sql.QueryRow(`SELECT secret FROM ssh_hosts WHERE id = ?`, id).Scan(&raw)
|
||||
if raw == "" || strings.Contains(raw, "s3cret-pass") {
|
||||
t.Fatalf("secret stored as %q", raw)
|
||||
}
|
||||
got := &proto.SSHTarget{Saved: id}
|
||||
if err := s.fillSaved(a.ID, got); err != nil || got.Password != "s3cret-pass" || got.Host != "h" || got.User != "root" || got.Saved != 0 {
|
||||
t.Fatalf("own: %+v %v", got, err)
|
||||
}
|
||||
if err := s.fillSaved(a.ID+1, &proto.SSHTarget{Saved: id}); err == nil {
|
||||
t.Fatal("another user got the host")
|
||||
}
|
||||
if list := s.listSSHHosts(a.ID); len(list) != 1 || list[0].Label != "web" {
|
||||
t.Fatalf("list: %+v", list)
|
||||
}
|
||||
if len(s.listSSHHosts(a.ID+1)) != 0 {
|
||||
t.Fatal("another user sees the host")
|
||||
}
|
||||
}
|
||||
|
||||
// The routes: a saved host shows without its secret.
|
||||
func TestSavedSSHHostsRoute(t *testing.T) {
|
||||
s, c := newTestServer(t)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/panel/ssh/hosts", strings.NewReader(`{"label":"db","host":"h2","user":"u","auth":"password","password":"pw-xyz"}`))
|
||||
req.AddCookie(c)
|
||||
w := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(w, req)
|
||||
if !strings.Contains(w.Body.String(), `"ok":true`) {
|
||||
t.Fatalf("save: %s", w.Body.String())
|
||||
}
|
||||
w = get(t, s, c, "/api/panel/ssh/hosts")
|
||||
if !strings.Contains(w.Body.String(), `"h2"`) || strings.Contains(w.Body.String(), "pw-xyz") {
|
||||
t.Fatalf("list: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
)
|
||||
|
||||
func TestStaticHidesTests(t *testing.T) {
|
||||
fsys := fstest.MapFS{
|
||||
"wsutil.js": {Data: []byte("x")},
|
||||
"wsutil.test.js": {Data: []byte("y")},
|
||||
}
|
||||
h := staticHandler(fsys, map[string]string{})
|
||||
for path, want := range map[string]int{"/wsutil.js": 200, "/wsutil.test.js": 404} {
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
if w.Code != want {
|
||||
t.Errorf("%s: got %d, want %d", path, w.Code, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A terminal opened with a folder asks the agent to start there; a folder
|
||||
// that is not an absolute path is left out (the shell starts at home).
|
||||
func TestPanelOpenDir(t *testing.T) {
|
||||
s, cookie := newTestServer(t)
|
||||
for body, want := range map[string]string{
|
||||
`{"dir":"/srv/my app"}`: "/srv/my app",
|
||||
`{"dir":"relative"}`: "",
|
||||
``: "",
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/panel/term/x1/open", strings.NewReader(body))
|
||||
req.AddCookie(cookie)
|
||||
w := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(w, req)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("%s: code %d %s", body, w.Code, w.Body)
|
||||
}
|
||||
s.hub.mu.Lock()
|
||||
jobs := s.hub.jobs["x1"]
|
||||
s.hub.jobs["x1"] = nil
|
||||
s.hub.mu.Unlock()
|
||||
if len(jobs) != 1 || jobs[0].Dir != want {
|
||||
t.Fatalf("%s: jobs %+v, want dir %q", body, jobs, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+732
@@ -0,0 +1,732 @@
|
||||
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=3.0, user-scalable=1;">
|
||||
<meta name="referrer" content="origin">
|
||||
<meta name="HandheldFriendly" content="true" />
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<title>golang context cancel at DuckDuckGo</title>
|
||||
<link title="DuckDuckGo (Lite)" type="application/opensearchdescription+xml" rel="search" href="//duckduckgo.com/opensearch_lite_v2.xml">
|
||||
<link href="//duckduckgo.com/favicon.ico" rel="shortcut icon" />
|
||||
<link rel="icon" href="//duckduckgo.com/favicon.ico" type="image/x-icon" />
|
||||
<link id="icon60" rel="apple-touch-icon" href="//duckduckgo.com/assets/icons/meta/DDG-iOS-icon_60x60.png?v=2"/>
|
||||
<link id="icon76" rel="apple-touch-icon" sizes="76x76" href="//duckduckgo.com/assets/icons/meta/DDG-iOS-icon_76x76.png?v=2"/>
|
||||
<link id="icon120" rel="apple-touch-icon" sizes="120x120" href="//duckduckgo.com/assets/icons/meta/DDG-iOS-icon_120x120.png?v=2"/>
|
||||
<link id="icon152" rel="apple-touch-icon" sizes="152x152" href="//duckduckgo.com/assets/icons/meta/DDG-iOS-icon_152x152.png?v=2"/>
|
||||
<link rel="image_src" href="//duckduckgo.com/assets/icons/meta/DDG-icon_256x256.png">
|
||||
<link rel="stylesheet" media="handheld, all" href="//duckduckgo.com/dist/lr.48ddfe4eadf6a534e93f.css" type="text/css"/>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<p class='extra'> </p>
|
||||
<div class="header">
|
||||
DuckDuckGo
|
||||
</div>
|
||||
<p class='extra'> </p>
|
||||
|
||||
<form action="/lite/" method="post">
|
||||
<input class='query' type="text" size="40" name="q" value="golang context cancel">
|
||||
<input class='submit' type="submit" value="Search">
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="filters">
|
||||
<select class="submit" name="kl">
|
||||
|
||||
<option value="" >All Regions</option>
|
||||
|
||||
<option value="ar-es" >Argentina</option>
|
||||
|
||||
<option value="au-en" >Australia</option>
|
||||
|
||||
<option value="at-de" >Austria</option>
|
||||
|
||||
<option value="be-fr" >Belgium (fr)</option>
|
||||
|
||||
<option value="be-nl" >Belgium (nl)</option>
|
||||
|
||||
<option value="br-pt" >Brazil</option>
|
||||
|
||||
<option value="bg-bg" >Bulgaria</option>
|
||||
|
||||
<option value="ca-en" >Canada (en)</option>
|
||||
|
||||
<option value="ca-fr" >Canada (fr)</option>
|
||||
|
||||
<option value="ct-ca" >Catalonia</option>
|
||||
|
||||
<option value="cl-es" >Chile</option>
|
||||
|
||||
<option value="cn-zh" >China</option>
|
||||
|
||||
<option value="co-es" >Colombia</option>
|
||||
|
||||
<option value="hr-hr" >Croatia</option>
|
||||
|
||||
<option value="cz-cs" >Czech Republic</option>
|
||||
|
||||
<option value="dk-da" >Denmark</option>
|
||||
|
||||
<option value="ee-et" >Estonia</option>
|
||||
|
||||
<option value="fi-fi" >Finland</option>
|
||||
|
||||
<option value="fr-fr" >France</option>
|
||||
|
||||
<option value="de-de" >Germany</option>
|
||||
|
||||
<option value="gr-el" >Greece</option>
|
||||
|
||||
<option value="hk-tzh" >Hong Kong</option>
|
||||
|
||||
<option value="hu-hu" >Hungary</option>
|
||||
|
||||
<option value="is-is" >Iceland</option>
|
||||
|
||||
<option value="in-en" >India (en)</option>
|
||||
|
||||
<option value="id-en" >Indonesia (en)</option>
|
||||
|
||||
<option value="ie-en" >Ireland</option>
|
||||
|
||||
<option value="il-en" >Israel (en)</option>
|
||||
|
||||
<option value="it-it" >Italy</option>
|
||||
|
||||
<option value="jp-jp" >Japan</option>
|
||||
|
||||
<option value="kr-kr" >Korea</option>
|
||||
|
||||
<option value="lv-lv" >Latvia</option>
|
||||
|
||||
<option value="lt-lt" >Lithuania</option>
|
||||
|
||||
<option value="my-en" >Malaysia (en)</option>
|
||||
|
||||
<option value="mx-es" >Mexico</option>
|
||||
|
||||
<option value="nl-nl" >Netherlands</option>
|
||||
|
||||
<option value="nz-en" >New Zealand</option>
|
||||
|
||||
<option value="no-no" >Norway</option>
|
||||
|
||||
<option value="pk-en" >Pakistan (en)</option>
|
||||
|
||||
<option value="pe-es" >Peru</option>
|
||||
|
||||
<option value="ph-en" >Philippines (en)</option>
|
||||
|
||||
<option value="pl-pl" >Poland</option>
|
||||
|
||||
<option value="pt-pt" >Portugal</option>
|
||||
|
||||
<option value="ro-ro" >Romania</option>
|
||||
|
||||
<option value="ru-ru" >Russia</option>
|
||||
|
||||
<option value="xa-ar" >Saudi Arabia</option>
|
||||
|
||||
<option value="sg-en" >Singapore</option>
|
||||
|
||||
<option value="sk-sk" >Slovakia</option>
|
||||
|
||||
<option value="sl-sl" >Slovenia</option>
|
||||
|
||||
<option value="za-en" >South Africa</option>
|
||||
|
||||
<option value="es-ca" >Spain (ca)</option>
|
||||
|
||||
<option value="es-es" >Spain (es)</option>
|
||||
|
||||
<option value="se-sv" >Sweden</option>
|
||||
|
||||
<option value="ch-de" >Switzerland (de)</option>
|
||||
|
||||
<option value="ch-fr" >Switzerland (fr)</option>
|
||||
|
||||
<option value="tw-tzh" >Taiwan</option>
|
||||
|
||||
<option value="th-en" >Thailand (en)</option>
|
||||
|
||||
<option value="tr-tr" >Turkey</option>
|
||||
|
||||
<option value="us-en" >US (English)</option>
|
||||
|
||||
<option value="us-es" >US (Spanish)</option>
|
||||
|
||||
<option value="ua-uk" >Ukraine</option>
|
||||
|
||||
<option value="uk-en" >United Kingdom</option>
|
||||
|
||||
<option value="vn-en" >Vietnam (en)</option>
|
||||
|
||||
</select>
|
||||
|
||||
<select class="submit" name="df">
|
||||
|
||||
<option value="" selected>Any Time</option>
|
||||
|
||||
<option value="d" >Past Day</option>
|
||||
|
||||
<option value="w" >Past Week</option>
|
||||
|
||||
<option value="m" >Past Month</option>
|
||||
|
||||
<option value="y" >Past Year</option>
|
||||
|
||||
</select>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
||||
<p class="extra"> </p>
|
||||
<table border="0">
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
<!-- Next Page Button Sub-template -->
|
||||
<form class="next_form" action="/lite/" method="post">
|
||||
<input type="submit" class='navbutton' value="Next Page >">
|
||||
<input type="hidden" name="q" value="golang context cancel">
|
||||
<input type="hidden" name="s" value="10">
|
||||
<input type="hidden" name="nextParams" value="">
|
||||
<input type="hidden" name="v" value="l">
|
||||
<input type="hidden" name="o" value="json">
|
||||
<input type="hidden" name="dc" value="11">
|
||||
<input type="hidden" name="api" value="d.js">
|
||||
<input type="hidden" name="vqd" value="4-288446547072343849888557955747865435943">
|
||||
|
||||
|
||||
|
||||
|
||||
<input name="kl" value="wt-wt" type="hidden">
|
||||
|
||||
|
||||
|
||||
|
||||
</form>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
|
||||
|
||||
<p class='extra'> </p>
|
||||
|
||||
<table border="0">
|
||||
|
||||
</table>
|
||||
|
||||
<table border="0">
|
||||
|
||||
|
||||
<!-- Web results are present -->
|
||||
|
||||
|
||||
<tr>
|
||||
|
||||
<td valign="top">
|
||||
|
||||
1.
|
||||
|
||||
</td>
|
||||
<td>
|
||||
<a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fgo.dev%2Fdoc%2Fdatabase%2Fcancel%2Doperations&rut=114b1aa13c9158c8c2b077ccd5f890f8a940277c1fc938e7bdcaad636c444a06" class='result-link'>Canceling in-progress operations - The Go Programming Language</a>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
|
||||
|
||||
<!-- Only show abstract separately if there's a click URL (not EOF) -->
|
||||
<tr>
|
||||
<td> </td>
|
||||
<td class='result-snippet'>
|
||||
Learn how to use <b>context</b>.<b>Context</b> to manage and <b>cancel</b> database operations in Go. See examples of how to set a timeout, deadline, and propagate cancellation requests with <b>context</b>.
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
|
||||
|
||||
<tr>
|
||||
<td> </td>
|
||||
<td>
|
||||
<span class='link-text'>go.dev/doc/database/cancel-operations</span>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
<tr>
|
||||
<td> </td>
|
||||
<td> </td>
|
||||
</tr>
|
||||
|
||||
|
||||
|
||||
|
||||
<tr>
|
||||
|
||||
<td valign="top">
|
||||
|
||||
2.
|
||||
|
||||
</td>
|
||||
<td>
|
||||
<a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fgolangtutorial.com%2Fgolang%2Dcontext%2F&rut=c8e3cbb5601e47ba9c55f0ad6b52790e09acc1c0d3caf33c676b68ee65a1f419" class='result-link'>Go Context Tutorial: Cancellation and Deadlines</a>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
|
||||
|
||||
<!-- Only show abstract separately if there's a click URL (not EOF) -->
|
||||
<tr>
|
||||
<td> </td>
|
||||
<td class='result-snippet'>
|
||||
Learn Go <b>context</b> with practical cancellation, timeout, HTTP request, and goroutine examples.
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
|
||||
|
||||
<tr>
|
||||
<td> </td>
|
||||
<td>
|
||||
<span class='link-text'>golangtutorial.com/golang-context/</span>
|
||||
|
||||
|
||||
<span class='timestamp'>2026-07-29T00:00:00.0000000</span>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
<tr>
|
||||
<td> </td>
|
||||
<td> </td>
|
||||
</tr>
|
||||
|
||||
|
||||
|
||||
|
||||
<tr>
|
||||
|
||||
<td valign="top">
|
||||
|
||||
3.
|
||||
|
||||
</td>
|
||||
<td>
|
||||
<a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fstackoverflow.com%2Fquestions%2F52799280%2Fcontext%2Dconfusion%2Dregarding%2Dcancellation&rut=0fa1c305fa28cede62f85c49d0501954cac2eba160d5c48f155a1008367ae78c" class='result-link'>go - Context confusion regarding cancellation - Stack Overflow</a>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
|
||||
|
||||
<!-- Only show abstract separately if there's a click URL (not EOF) -->
|
||||
<tr>
|
||||
<td> </td>
|
||||
<td class='result-snippet'>
|
||||
Additional info #1: calling <b>cancel</b> () right after <b>context</b> initialized As per <b>context</b> documentation regarding the <b>cancel</b> () function: Canceling this <b>context</b> releases resources associated with it, so code should call <b>cancel</b> as soon as the operations running in this <b>Context</b> complete. It's good to always call <b>cancel</b> () function right after the <b>context</b> declaration. doesn't matter whether it's also ...
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
|
||||
|
||||
<tr>
|
||||
<td> </td>
|
||||
<td>
|
||||
<span class='link-text'>stackoverflow.com/questions/52799280/context-confusion-regarding-cancellation</span>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
<tr>
|
||||
<td> </td>
|
||||
<td> </td>
|
||||
</tr>
|
||||
|
||||
|
||||
|
||||
|
||||
<tr>
|
||||
|
||||
<td valign="top">
|
||||
|
||||
4.
|
||||
|
||||
</td>
|
||||
<td>
|
||||
<a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fpkg.go.dev%2Fcontext&rut=925dd34b1fc94c2b0cb26f6e920e10aa3131c3cfc14163fd7c01b17554611b77" class='result-link'>context package - context - Go Packages</a>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
|
||||
|
||||
<!-- Only show abstract separately if there's a click URL (not EOF) -->
|
||||
<tr>
|
||||
<td> </td>
|
||||
<td class='result-snippet'>
|
||||
Package <b>context</b> defines the <b>Context</b> type, which carries deadlines, cancellation signals, and other request-scoped values across API boundaries and between processes.
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
|
||||
|
||||
<tr>
|
||||
<td> </td>
|
||||
<td>
|
||||
<span class='link-text'>pkg.go.dev/context</span>
|
||||
|
||||
|
||||
<span class='timestamp'>2026-10-08T00:00:00.0000000</span>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
<tr>
|
||||
<td> </td>
|
||||
<td> </td>
|
||||
</tr>
|
||||
|
||||
|
||||
|
||||
|
||||
<tr>
|
||||
|
||||
<td valign="top">
|
||||
|
||||
5.
|
||||
|
||||
</td>
|
||||
<td>
|
||||
<a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fstackoverflow.com%2Fquestions%2F70042646%2Fbest%2Dpractices%2Don%2Dgo%2Dcontext%2Dcancelation%2Dfunctions&rut=f3a199db4b71e24ff5a3b0d4cbf7241ee53a03312bc2e2bcadf6d9cb20aaa474" class='result-link'>Best practices on go context cancelation functions</a>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
|
||||
|
||||
<!-- Only show abstract separately if there's a click URL (not EOF) -->
|
||||
<tr>
|
||||
<td> </td>
|
||||
<td class='result-snippet'>
|
||||
So, is passing this cancellation function actually an issue? Are there any best practices related to the use of the <b>context</b> package and their <b>cancel</b> function?
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
|
||||
|
||||
<tr>
|
||||
<td> </td>
|
||||
<td>
|
||||
<span class='link-text'>stackoverflow.com/questions/70042646/best-practices-on-go-context-cancelation-functions</span>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
<tr>
|
||||
<td> </td>
|
||||
<td> </td>
|
||||
</tr>
|
||||
|
||||
|
||||
|
||||
|
||||
<tr>
|
||||
|
||||
<td valign="top">
|
||||
|
||||
6.
|
||||
|
||||
</td>
|
||||
<td>
|
||||
<a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fgolangbot.com%2Fcontext%2Dtimeout%2Dcancellation%2F&rut=dc6314fa589ab9eac3160a9b8df6a7ca4604e480917f0b99cf558d081c78ab02" class='result-link'>Golang Context - Cancellation, Timeout and Propagation | golangbot.com</a>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
|
||||
|
||||
<!-- Only show abstract separately if there's a click URL (not EOF) -->
|
||||
<tr>
|
||||
<td> </td>
|
||||
<td class='result-snippet'>
|
||||
Learn how to <b>cancel</b> long running tasks in Go using <b>context</b> cancellation and timeout. This tutorial also teaches how to terminate outstanding web requests which were cancelled by the client.
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
|
||||
|
||||
<tr>
|
||||
<td> </td>
|
||||
<td>
|
||||
<span class='link-text'>golangbot.com/context-timeout-cancellation/</span>
|
||||
|
||||
|
||||
<span class='timestamp'>2026-07-08T00:00:00.0000000</span>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
<tr>
|
||||
<td> </td>
|
||||
<td> </td>
|
||||
</tr>
|
||||
|
||||
|
||||
|
||||
|
||||
<tr>
|
||||
|
||||
<td valign="top">
|
||||
|
||||
7.
|
||||
|
||||
</td>
|
||||
<td>
|
||||
<a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fgo.dev%2Fblog%2Fcontext&rut=6d324e0f7c589513830578b05d2a0a5ccedf509c8d4181ac590b3a229c8b9057" class='result-link'>Go Concurrency Patterns: Context - The Go Programming Language</a>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
|
||||
|
||||
<!-- Only show abstract separately if there's a click URL (not EOF) -->
|
||||
<tr>
|
||||
<td> </td>
|
||||
<td class='result-snippet'>
|
||||
Learn how to use the <b>context</b> package to pass request-scoped values, cancellation signals, and deadlines across API boundaries in Go servers. See a complete example of an HTTP server that handles Google Web Search requests with <b>context</b>.
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
|
||||
|
||||
<tr>
|
||||
<td> </td>
|
||||
<td>
|
||||
<span class='link-text'>go.dev/blog/context</span>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
<tr>
|
||||
<td> </td>
|
||||
<td> </td>
|
||||
</tr>
|
||||
|
||||
|
||||
|
||||
|
||||
<tr>
|
||||
|
||||
<td valign="top">
|
||||
|
||||
8.
|
||||
|
||||
</td>
|
||||
<td>
|
||||
<a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.sohamkamani.com%2Fgolang%2Fcontext%2F&rut=7274980ce55c2a5b88f9299c12e1228a7ecfffbb5fabbab8a837cbad990a1a87" class='result-link'>How to Use Context in Golang (Deadlines, Cancellation, and Passing Values)</a>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
|
||||
|
||||
<!-- Only show abstract separately if there's a click URL (not EOF) -->
|
||||
<tr>
|
||||
<td> </td>
|
||||
<td class='result-snippet'>
|
||||
In this post, we'll learn about Go's <b>context</b> package, and more specifically, how we can use it to improve our applications performance. The main purpose of using <b>Context</b> is to manage long-running processes (like an HTTP request, a database call, or a network call) in a way that doesn't waste resources. If used correctly, the <b>context</b> package can help you: <b>Cancel</b> long running processes ...
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
|
||||
|
||||
<tr>
|
||||
<td> </td>
|
||||
<td>
|
||||
<span class='link-text'>www.sohamkamani.com/golang/context/</span>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
<tr>
|
||||
<td> </td>
|
||||
<td> </td>
|
||||
</tr>
|
||||
|
||||
|
||||
|
||||
|
||||
<tr>
|
||||
|
||||
<td valign="top">
|
||||
|
||||
9.
|
||||
|
||||
</td>
|
||||
<td>
|
||||
<a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fgolangdoc.github.io%2Fpkg%2F1.12%2Fcontext%2Findex.html&rut=4baaf27a174aa7e8bbcc0cfe1985ad916fe3ff014eb45fc06dd68178fc84e261" class='result-link'>context - The Go Programming Language</a>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
|
||||
|
||||
<!-- Only show abstract separately if there's a click URL (not EOF) -->
|
||||
<tr>
|
||||
<td> </td>
|
||||
<td class='result-snippet'>
|
||||
The returned <b>context's</b> Done channel is closed when the deadline expires, when the returned <b>cancel</b> function is called, or when the parent <b>context's</b> Done channel is closed, whichever happens first.
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
|
||||
|
||||
<tr>
|
||||
<td> </td>
|
||||
<td>
|
||||
<span class='link-text'>golangdoc.github.io/pkg/1.12/context/index.html</span>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
<tr>
|
||||
<td> </td>
|
||||
<td> </td>
|
||||
</tr>
|
||||
|
||||
|
||||
|
||||
|
||||
<tr>
|
||||
|
||||
<td valign="top">
|
||||
|
||||
10.
|
||||
|
||||
</td>
|
||||
<td>
|
||||
<a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.golinuxcloud.com%2Fgolang%2Dcontext%2F&rut=efdde246db353602fc52ef95d8d09a146270aeb46114aa061f320d037adea574" class='result-link'>Golang context — WithCancel, WithTimeout, WithDeadline, Background</a>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
|
||||
|
||||
<!-- Only show abstract separately if there's a click URL (not EOF) -->
|
||||
<tr>
|
||||
<td> </td>
|
||||
<td class='result-snippet'>
|
||||
The <b>golang</b> <b>context</b> package ties cancellation, timeouts, and optional request values to a <b>Context</b> value you pass through your call stack. Use Background (or TODO temporarily) as the root, WithTimeout / WithDeadline for time bounds, WithCancel for explicit shutdown, and WithValue only for small, request-scoped metadata—always defer <b>cancel</b> ...
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
|
||||
|
||||
<tr>
|
||||
<td> </td>
|
||||
<td>
|
||||
<span class='link-text'>www.golinuxcloud.com/golang-context/</span>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
<tr>
|
||||
<td> </td>
|
||||
<td> </td>
|
||||
</tr>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<tr>
|
||||
<td colspan=2>
|
||||
|
||||
|
||||
<!-- Next Page Button Sub-template -->
|
||||
<form class="next_form" action="/lite/" method="post">
|
||||
<input type="submit" class='navbutton' value="Next Page >">
|
||||
<input type="hidden" name="q" value="golang context cancel">
|
||||
<input type="hidden" name="s" value="10">
|
||||
<input type="hidden" name="nextParams" value="">
|
||||
<input type="hidden" name="v" value="l">
|
||||
<input type="hidden" name="o" value="json">
|
||||
<input type="hidden" name="dc" value="11">
|
||||
<input type="hidden" name="api" value="d.js">
|
||||
<input type="hidden" name="vqd" value="4-288446547072343849888557955747865435943">
|
||||
|
||||
|
||||
|
||||
|
||||
<input name="kl" value="wt-wt" type="hidden">
|
||||
|
||||
|
||||
|
||||
|
||||
</form>
|
||||
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
<p class='extra'> </p>
|
||||
|
||||
<form action="/lite/" method="post">
|
||||
<input class='query' type="text" size="40" name="q" value="golang context cancel" >
|
||||
<input class='submit' type="submit" value="Search">
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<input name="kl" value="wt-wt" type="hidden">
|
||||
|
||||
</form>
|
||||
|
||||
<p class='extra'> </p>
|
||||
|
||||
|
||||
<img src="//duckduckgo.com/t/sl_l"/>
|
||||
|
||||
<div id='end-spacer'> </div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,89 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// GET /api/panel/users/{id}/agents - every machine, and which of them the
|
||||
// user may use, for the editor of a user's machines.
|
||||
func (s *Server) handleUserAgents(w http.ResponseWriter, r *http.Request) {
|
||||
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
u, _ := s.db.AdminByID(id)
|
||||
if u == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
agents, _ := s.db.ListAgents()
|
||||
mine := s.db.UserAgents(id)
|
||||
type item struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Hostname string `json:"hostname"`
|
||||
Online bool `json:"online"`
|
||||
On bool `json:"on"`
|
||||
}
|
||||
out := make([]item, 0, len(agents))
|
||||
for _, a := range agents {
|
||||
out = append(out, item{a.ID, a.Name, a.Hostname, s.online(&a), mine[a.ID]})
|
||||
}
|
||||
writeJSON(w, map[string]any{"username": u.Username, "agents": out})
|
||||
}
|
||||
|
||||
// POST /api/panel/users/{id} - change a user: the role, the machines, the
|
||||
// password sign-in, and whether they may switch it themselves. Fields left
|
||||
// out stay as they are.
|
||||
func (s *Server) handleUserEdit(w http.ResponseWriter, r *http.Request) {
|
||||
me := s.currentAdmin(r)
|
||||
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
u, _ := s.db.AdminByID(id)
|
||||
if u == nil {
|
||||
writeJSON(w, map[string]any{"ok": false, "error": "нет такого пользователя"})
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
Role *string `json:"role"`
|
||||
Agents *[]string `json:"agents"`
|
||||
PasswordOff *bool `json:"password_off"`
|
||||
PwLocked *bool `json:"pw_locked"`
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&in); err != nil {
|
||||
writeJSON(w, map[string]any{"ok": false, "error": "плохой запрос"})
|
||||
return
|
||||
}
|
||||
fail := func(msg string) { writeJSON(w, map[string]any{"ok": false, "error": msg}) }
|
||||
if in.Role != nil {
|
||||
role := *in.Role
|
||||
if role != RoleAdmin && role != RoleUser {
|
||||
fail("нет такой роли")
|
||||
return
|
||||
}
|
||||
if role == RoleUser && u.IsAdmin() && s.db.CountRole(RoleAdmin) <= 1 {
|
||||
fail("это последний администратор")
|
||||
return
|
||||
}
|
||||
_ = s.db.SetUserRole(id, role)
|
||||
s.db.Audit(me.ID, "", "user_role:"+u.Username+":"+role)
|
||||
}
|
||||
if in.Agents != nil {
|
||||
_ = s.db.SetUserAgents(id, *in.Agents)
|
||||
s.db.Audit(me.ID, "", "user_agents:"+u.Username+":"+strconv.Itoa(len(*in.Agents)))
|
||||
}
|
||||
if in.PasswordOff != nil {
|
||||
if *in.PasswordOff && s.db.CountAdminKeys(id) == 0 {
|
||||
fail("у пользователя нет ключей доступа - без пароля он не войдёт")
|
||||
return
|
||||
}
|
||||
_ = s.db.SetPasswordOff(id, *in.PasswordOff)
|
||||
action := "password_on"
|
||||
if *in.PasswordOff {
|
||||
action = "password_off"
|
||||
}
|
||||
s.db.Audit(me.ID, "", action+":"+u.Username)
|
||||
}
|
||||
if in.PwLocked != nil {
|
||||
_ = s.db.SetPwLocked(id, *in.PwLocked)
|
||||
}
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
}
|
||||
@@ -7,6 +7,10 @@
|
||||
set -eu
|
||||
|
||||
HOST="${HOST:-{{.Host}}}"
|
||||
# The server's other address (the direct one, not behind a proxy like
|
||||
# Cloudflare, or the main one when this script came from the direct one): the
|
||||
# agent turns to it when HOST is out of reach.
|
||||
FALLBACK_HOST="${FALLBACK_HOST:-{{.Fallback}}}"
|
||||
{{if .Secret}}ENROLL_SECRET="${ENROLL_SECRET:-{{.Secret}}}"
|
||||
{{end}}
|
||||
BIN_PATH="/usr/local/bin/lpagent"
|
||||
@@ -108,9 +112,9 @@ fi
|
||||
# --- downloader -------------------------------------------------------------
|
||||
download() { # download URL DEST
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl -fSL -o "$2" "$1"
|
||||
curl -fSL --connect-timeout 15 -o "$2" "$1"
|
||||
elif command -v wget >/dev/null 2>&1; then
|
||||
wget -qO "$2" "$1"
|
||||
wget -q -T 30 -O "$2" "$1"
|
||||
else
|
||||
red "Нужен curl или wget."; exit 1
|
||||
fi
|
||||
@@ -118,7 +122,14 @@ download() { # download URL DEST
|
||||
|
||||
bold "Скачивание агента ($ARCH) с $HOST…"
|
||||
TMP="$(mktemp)"
|
||||
download "$HOST/download/agent-linux-$ARCH" "$TMP"
|
||||
if ! download "$HOST/download/agent-linux-$ARCH" "$TMP"; then
|
||||
if [ -n "$FALLBACK_HOST" ] && [ "$FALLBACK_HOST" != "$HOST" ]; then
|
||||
bold "$HOST недоступен, скачивание с $FALLBACK_HOST…"
|
||||
download "$FALLBACK_HOST/download/agent-linux-$ARCH" "$TMP"
|
||||
else
|
||||
red "Не удалось скачать агента."; exit 1
|
||||
fi
|
||||
fi
|
||||
install -m 0755 "$TMP" "$BIN_PATH"
|
||||
rm -f "$TMP"
|
||||
green "Бинарник установлен: $BIN_PATH"
|
||||
@@ -129,6 +140,7 @@ chmod 700 "$CONF_DIR"
|
||||
umask 077
|
||||
cat > "$CONF_FILE" <<EOF
|
||||
HOST=$HOST
|
||||
FALLBACK_HOST=$FALLBACK_HOST
|
||||
ENROLL_SECRET=${ENROLL_SECRET:-}
|
||||
AGENT_NAME=$AGENT_NAME
|
||||
STATE_PATH=$STATE_FILE
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
// The account button in the top bar: a dialog with who is signed in, their
|
||||
// passkeys (add one, remove one), the password switch and signing out. The
|
||||
// password can be turned off once there is a passkey to sign in with.
|
||||
(function () {
|
||||
'use strict';
|
||||
var btn = document.getElementById('acct');
|
||||
if (!btn) return;
|
||||
|
||||
var b64u = {
|
||||
enc: function (buf) {
|
||||
var s = '', b = new Uint8Array(buf);
|
||||
for (var i = 0; i < b.length; i++) s += String.fromCharCode(b[i]);
|
||||
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
},
|
||||
dec: function (str) {
|
||||
var s = atob(str.replace(/-/g, '+').replace(/_/g, '/') + '==='.slice((str.length + 3) % 4));
|
||||
var b = new Uint8Array(s.length);
|
||||
for (var i = 0; i < s.length; i++) b[i] = s.charCodeAt(i);
|
||||
return b.buffer;
|
||||
}
|
||||
};
|
||||
var esc = function (s) { return String(s).replace(/[&<>"]/g, function (c) { return {'&': '&', '<': '<', '>': '>', '"': '"'}[c]; }); };
|
||||
var when = function (t) { return t ? new Date(t * 1000).toLocaleString() : 'ещё не использовался'; };
|
||||
var csrf = (document.cookie.match(/(?:^|; )lpra_csrf=([^;]*)/) || [])[1] || '';
|
||||
var X = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>';
|
||||
var KEY = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="7.5" cy="15.5" r="4.5"/><path d="m21 2-9.6 9.6M15.5 7.5l3 3L22 7l-3-3"/></svg>';
|
||||
|
||||
var dlg = document.createElement('dialog');
|
||||
dlg.id = 'acctdlg';
|
||||
dlg.tabIndex = -1;
|
||||
dlg.innerHTML =
|
||||
'<div class="dlg-h"><strong style="flex:1">Аккаунт</strong>' +
|
||||
'<button type="button" class="btn ghost small icon close x" data-close title="Закрыть" aria-label="Закрыть">' + X + '</button></div>' +
|
||||
'<div class="dlg-b acct">' +
|
||||
' <div class="acct-who"><span class="acct-ava"></span><div class="acct-whotext"><div class="acct-name"></div><div class="muted acct-mode"></div></div>' +
|
||||
' <form method="post" action="/logout" class="acct-out"><input type="hidden" name="csrf"><button type="submit" class="btn ghost">Выйти</button></form></div>' +
|
||||
' <label class="acct-pw"><span><b>Вход по паролю</b><small class="muted acct-pwnote"></small></span>' +
|
||||
' <input type="checkbox" class="sw" role="switch"></label>' +
|
||||
' <label class="acct-pw acct-ai"><span><b>ИИ-ассистент</b><small class="muted">кнопка чата в правом нижнем углу</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>' +
|
||||
' <details class="acct-view"><summary>Вид</summary><div class="acct-viewin">' +
|
||||
' <label><span>Значки вкладок</span><select data-pref="tabIcons"><option value="">слева</option><option value="right">справа</option><option value="hide">отключены</option></select></label>' +
|
||||
' <label><span>Шрифт терминала</span><select data-pref="termFont" data-num><option value="0">обычный</option><option value="12">12</option><option value="13">13</option><option value="14">14</option><option value="15">15</option><option value="16">16</option><option value="18">18</option><option value="20">20</option></select></label>' +
|
||||
' <label><span>Списки файлов</span><select data-pref="compact" data-bool><option value="">обычные</option><option value="1">компактные</option></select></label>' +
|
||||
' <label><span>Время изменения файлов</span><select data-pref="fileTime"><option value="">показывать</option><option value="hide">скрыть</option></select></label>' +
|
||||
' <label><span>Анимации</span><select data-pref="motion"><option value="">включены</option><option value="off">выключены</option></select></label>' +
|
||||
' <label><span>«Здесь» в меню «+»</span><select data-pref="openHere"><option value="">в папке активной вкладки</option><option value="off">в домашней папке</option></select></label>' +
|
||||
' </div></details>' +
|
||||
' <div class="acct-keysec">' +
|
||||
' <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>' +
|
||||
' </div>' +
|
||||
'</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(); };
|
||||
// Вид: each change applies at once and is kept on the server.
|
||||
dlg.querySelectorAll('[data-pref]').forEach(function (sel) {
|
||||
var k = sel.dataset.pref, cur = window.Prefs ? Prefs.get()[k] : '';
|
||||
sel.value = sel.hasAttribute('data-bool') ? (cur ? '1' : '') : String(cur || (sel.hasAttribute('data-num') ? 0 : ''));
|
||||
sel.onchange = function () {
|
||||
var v = sel.hasAttribute('data-bool') ? sel.value === '1' : sel.hasAttribute('data-num') ? Number(sel.value) : sel.value;
|
||||
if (window.Prefs) Prefs.set(k, v).then(function (ok) { if (!ok) msg('не сохранилось', true); });
|
||||
};
|
||||
});
|
||||
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 = async function () {
|
||||
var last = d.keys.length === 1;
|
||||
if (!await UI.confirm('Удалить ключ «' + k.name + '»?' + (last && d.password_off ? ' Это последний ключ: вход по паролю снова включится.' : ''), {ok: 'Удалить', danger: true})) 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 = async function () {
|
||||
if (await UI.confirm('Убрать PIN-код?', {ok: 'Убрать', danger: true})) savePin('');
|
||||
};
|
||||
// The assistant: each user's own switch; the chat button follows it.
|
||||
fetch('/api/panel/ai', {cache: 'no-store'}).then(function (r) { return r.json(); }).then(function (d) { $('.acct-ai .sw').checked = !!d.on; }).catch(function () {});
|
||||
$('.acct-ai .sw').onchange = function (ev) {
|
||||
var on = ev.target.checked;
|
||||
fetch('/api/panel/ai/toggle', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({on: on})})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (r) { if (!r.ok) { ev.target.checked = !on; msg(r.error, true); } else document.dispatchEvent(new CustomEvent('lpra:ai', {detail: on})); });
|
||||
};
|
||||
$('.acct-pw .sw').onchange = async function (ev) {
|
||||
const off = !ev.target.checked;
|
||||
if (off && !await UI.confirm('Выключить вход по паролю? Войти можно будет только ключом доступа. Если ключи потеряются, их сможет сбросить другой администратор в «Настройках».', {ok: 'Выключить', danger: true})) {
|
||||
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(); });
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,824 @@
|
||||
// The assistant: a round button in the bottom right corner opens a chat. Each
|
||||
// question goes with what the panel shows - the tabs, the active pane, the
|
||||
// text of its terminal or its list of files - and the answer streams in.
|
||||
// Commands the model suggests come as ```run``` blocks with «Выполнить»: a
|
||||
// click sends the command to the active terminal, with the user's rights.
|
||||
// On by default; the user can turn it off in the profile.
|
||||
(function () {
|
||||
'use strict';
|
||||
const PLANE = '<svg class="ai-plane" width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M3.4 20.4 20.85 12.9a1 1 0 0 0 0-1.8L3.4 3.6a1 1 0 0 0-1.39 1.17L4 11l9 1-9 1-1.99 6.23a1 1 0 0 0 1.39 1.17z"/></svg>';
|
||||
const STOP = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><rect x="5" y="5" width="14" height="14" rx="2"/></svg>';
|
||||
const esc = v => String(v).replace(/[&<>"]/g, c => ({'&': '&', '<': '<', '>': '>', '"': '"'}[c]));
|
||||
const fab = document.createElement('button');
|
||||
fab.type = 'button';
|
||||
fab.className = 'ai-fab';
|
||||
fab.hidden = true;
|
||||
fab.title = 'ИИ-ассистент';
|
||||
fab.setAttribute('aria-label', 'ИИ-ассистент');
|
||||
fab.innerHTML = '<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M10 2.5c.3 0 .5.2.6.5l1 3.7a5 5 0 0 0 3.6 3.6l3.7 1c.6.2.6 1 0 1.2l-3.7 1a5 5 0 0 0-3.6 3.6l-1 3.7c-.2.6-1 .6-1.2 0l-1-3.7a5 5 0 0 0-3.6-3.6l-3.7-1c-.6-.2-.6-1 0-1.2l3.7-1a5 5 0 0 0 3.6-3.6l1-3.7c.1-.3.3-.5.6-.5z"/><path d="M18.5 1.5c.2 0 .3.1.4.3l.4 1.4c.2.6.7 1.1 1.3 1.3l1.4.4c.4.1.4.6 0 .8l-1.4.4c-.6.2-1.1.7-1.3 1.3l-.4 1.4c-.1.4-.7.4-.8 0l-.4-1.4a2 2 0 0 0-1.3-1.3l-1.4-.4c-.4-.2-.4-.7 0-.8l1.4-.4c.6-.2 1.1-.7 1.3-1.3l.4-1.4c.1-.2.2-.3.4-.3z" opacity=".75"/></svg>';
|
||||
const box = document.createElement('div');
|
||||
box.className = 'ai-box';
|
||||
box.innerHTML =
|
||||
'<div class="ai-head"><button type="button" class="ai-chatsel" aria-expanded="false" title="Чаты"><span class="ai-chatname">Новый чат</span>' +
|
||||
'<svg class="chev" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="m9 6 6 6-6 6"/></svg></button>' +
|
||||
'<span class="muted ai-where"></span><span class="spacer"></span>' +
|
||||
'<button type="button" class="btn ghost small icon close x ai-close" aria-label="Закрыть"><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></button></div>' +
|
||||
'<div class="ai-chats" inert><div class="ai-chats-in"><button type="button" class="ai-chatnew">+ Новый чат</button><div class="ai-chatlist"></div></div></div>' +
|
||||
'<div class="ai-chat-body"><div class="ai-list"><div class="ai-hello"><span class="ai-hello-icon"><svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 9a2 2 0 0 1-2 2H6l-4 4V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2z"/><path d="M18 9h2a2 2 0 0 1 2 2v11l-4-4h-6a2 2 0 0 1-2-2v-1"/></svg></span><span class="ai-hello-text">Спросите AI помощника</span></div></div>' +
|
||||
'<button type="button" class="ai-jump" tabindex="-1" title="К последним сообщениям" aria-label="К последним сообщениям"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12l7 7 7-7"/></svg></button>' +
|
||||
'<div class="ai-file-error" role="alert"></div></div>' +
|
||||
'<div class="ai-pills">' +
|
||||
'<button type="button" class="ai-pill" data-mode="agent" aria-pressed="false" title="Сам выполняет шаги один за другим, спрашивая перед каждой командой и записью файла">' +
|
||||
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8V4H8"/><rect x="4" y="8" width="16" height="12" rx="2"/><path d="M2 14h2M20 14h2M9 13v2M15 13v2"/></svg>Агент</button>' +
|
||||
'<button type="button" class="ai-pill" data-mode="search" aria-pressed="false" title="Поиск в интернете (DuckDuckGo): ассистент ищет сам, когда нужно">' +
|
||||
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18"/></svg>Поиск</button>' +
|
||||
'<button type="button" class="ai-pill" data-mode="think" aria-pressed="false" title="Сначала обдумывает, потом отвечает: дольше, но точнее">' +
|
||||
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6M10 22h4"/><path d="M12 2a7 7 0 0 0-4 12.7c.6.5 1 1.3 1 2.3h6c0-1 .4-1.8 1-2.3A7 7 0 0 0 12 2z"/></svg>Думать</button>' +
|
||||
'</div>' +
|
||||
'<form class="ai-form-in"><input type="file" class="ai-file-input" multiple accept="image/*,audio/*,video/*,.txt,.md,.csv,.tsv,.json,.xml,.doc,.docx,.xlsx,.ods,.odt,.pdf,.mkv,.avi,.3gp,.tif,.tiff" hidden>' +
|
||||
'<div class="ai-inputbox"><div class="ai-attachments" hidden></div><div class="ai-entry">' +
|
||||
'<button type="button" class="ai-attach" title="Прикрепить файл" aria-label="Прикрепить файл"><svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.4 11.1-8.9 8.9a6 6 0 0 1-8.5-8.5l9.2-9.2a4 4 0 0 1 5.7 5.7l-9.2 9.2a2 2 0 0 1-2.8-2.8l8.5-8.5"/></svg></button>' +
|
||||
'<textarea rows="1" placeholder="Сообщение…"></textarea>' +
|
||||
'<button type="submit" class="ai-send" title="Отправить" aria-label="Отправить">' + PLANE + '</button></div></div></form>';
|
||||
document.body.append(fab, box);
|
||||
const q = s => box.querySelector(s);
|
||||
const list = q('.ai-list'), input = q('textarea'), send = q('.ai-send'), jump = q('.ai-jump');
|
||||
const attachInput = q('.ai-file-input'), attachList = q('.ai-attachments'), fileErr = q('.ai-file-error');
|
||||
let attachments = [];
|
||||
const importingPaneFiles = new Set();
|
||||
const nearBottom = () => list.scrollHeight - list.scrollTop - list.clientHeight < 80;
|
||||
function updateJump() { showJump(!nearBottom() && isOpen()); }
|
||||
// The arrow down fades in and out, midway between the right edge the
|
||||
// answers reach (their max-width, 92% of the list) and the chat's border.
|
||||
function showJump(on) {
|
||||
if (on && !jump.classList.contains('show')) {
|
||||
const body = list.parentNode.getBoundingClientRect(), l = list.getBoundingClientRect(), cs = getComputedStyle(list);
|
||||
const pl = parseFloat(cs.paddingLeft), inner = list.clientWidth - pl - parseFloat(cs.paddingRight);
|
||||
const gap = body.right - (l.left + list.clientLeft + pl + inner * 0.92);
|
||||
jump.style.right = Math.max(2, (gap - jump.offsetWidth) / 2) + 'px';
|
||||
}
|
||||
jump.classList.toggle('show', on);
|
||||
jump.tabIndex = on ? 0 : -1;
|
||||
}
|
||||
list.addEventListener('scroll', updateJump, {passive: true});
|
||||
jump.onclick = () => { list.scrollTo({top: list.scrollHeight, behavior: 'smooth'}); };
|
||||
function scrollIfFollowing(follow) { if (follow) list.scrollTop = list.scrollHeight; updateJump(); }
|
||||
// The talk lives on the server (the database); here only what is shown.
|
||||
let loaded = false;
|
||||
let ac = null;
|
||||
|
||||
// Open and shut with a short slide. On a phone the chat takes the screen
|
||||
// and is one step of history: «назад» shuts it.
|
||||
const phone = () => matchMedia('(max-width: 640px)').matches;
|
||||
const isOpen = () => box.classList.contains('on');
|
||||
function show() {
|
||||
loadTalk();
|
||||
placeBox();
|
||||
box.classList.add('on');
|
||||
fab.classList.add('away');
|
||||
requestAnimationFrame(updateJump);
|
||||
if (phone() && !(history.state && history.state.aiChat)) history.pushState({aiChat: 1}, '', location.href);
|
||||
where();
|
||||
if (!phone()) input.focus();
|
||||
}
|
||||
function hide() {
|
||||
if (!isOpen()) return;
|
||||
if (history.state && history.state.aiChat) { history.back(); return; }
|
||||
shut();
|
||||
}
|
||||
// shut: the chat goes, and the ball comes back in its corner - where the
|
||||
// chat was last moved to.
|
||||
function shut() {
|
||||
if (desk && !phone()) {
|
||||
const b = box.getBoundingClientRect(), o = box.style.transformOrigin || 'bottom right';
|
||||
const right = /right/.test(o) ? innerWidth - b.right : innerWidth - b.left - fab.offsetWidth;
|
||||
const bottom = /bottom/.test(o) ? innerHeight - b.bottom : innerHeight - b.top - fab.offsetHeight;
|
||||
const pos = placeFab(right, bottom);
|
||||
try { localStorage.setItem('lpra.ai.pos', JSON.stringify(pos)); } catch (e) { /* not kept */ }
|
||||
}
|
||||
box.classList.remove('on');
|
||||
fab.classList.remove('away');
|
||||
}
|
||||
window.addEventListener('popstate', () => { if (isOpen() && !(history.state && history.state.aiChat)) shut(); });
|
||||
function setOn(on) { fab.hidden = !on; if (!on) hide(); }
|
||||
const desk = !matchMedia('(pointer: coarse)').matches;
|
||||
fetch('/api/panel/ai', {cache: 'no-store', credentials: 'same-origin'}).then(r => r.json()).then(d => setOn(!!d.on)).catch(() => {});
|
||||
document.addEventListener('lpra:ai', ev => setOn(ev.detail));
|
||||
fab.onclick = () => { if (moved) { moved = false; return; } isOpen() ? hide() : show(); };
|
||||
|
||||
// ---------- on a computer: the ball and the chat move ----------
|
||||
// The ball goes where it is dragged and stays there (kept in this
|
||||
// browser, as distances from the right and bottom edges); the chat opens
|
||||
// beside it, toward the room, and moves by its title.
|
||||
let moved = false;
|
||||
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
||||
function placeFab(right, bottom) {
|
||||
right = clamp(right, 8, innerWidth - fab.offsetWidth - 8);
|
||||
bottom = clamp(bottom, 8, innerHeight - fab.offsetHeight - 8);
|
||||
fab.style.right = right + 'px'; fab.style.bottom = bottom + 'px';
|
||||
return {right, bottom};
|
||||
}
|
||||
function placeBox() {
|
||||
if (!desk || phone()) { box.style.left = box.style.top = box.style.right = box.style.bottom = ''; return; }
|
||||
const f = fab.getBoundingClientRect(), w = box.offsetWidth, h = box.offsetHeight, gap = 12;
|
||||
const toLeft = f.left + f.width / 2 > innerWidth / 2, up = f.top + f.height / 2 > innerHeight / 2;
|
||||
// The chat takes the ball's place, grown from its corner.
|
||||
const x = toLeft ? f.right - w : f.left;
|
||||
const y = up ? f.bottom - h : f.top;
|
||||
box.style.right = box.style.bottom = 'auto';
|
||||
box.style.left = clamp(x, 8, innerWidth - w - 8) + 'px';
|
||||
box.style.top = clamp(y, 8, innerHeight - h - 8) + 'px';
|
||||
box.style.transformOrigin = (up ? 'bottom ' : 'top ') + (toLeft ? 'right' : 'left');
|
||||
}
|
||||
if (desk) {
|
||||
try { const p = JSON.parse(localStorage.getItem('lpra.ai.pos') || 'null'); if (p) requestAnimationFrame(() => placeFab(p.right, p.bottom)); } catch (e) { /* default */ }
|
||||
const drag = (el, ev, onMove, onEnd) => {
|
||||
if (ev.button !== 0) return;
|
||||
const x0 = ev.clientX, y0 = ev.clientY;
|
||||
let on = false;
|
||||
const mv = e => {
|
||||
if (!on && Math.hypot(e.clientX - x0, e.clientY - y0) < 5) return;
|
||||
on = true;
|
||||
onMove(e.clientX - x0, e.clientY - y0);
|
||||
};
|
||||
const up = () => {
|
||||
removeEventListener('pointermove', mv); removeEventListener('pointerup', up);
|
||||
document.body.classList.remove('ai-dragging');
|
||||
if (on) onEnd();
|
||||
};
|
||||
addEventListener('pointermove', mv); addEventListener('pointerup', up);
|
||||
document.body.classList.add('ai-dragging');
|
||||
};
|
||||
fab.addEventListener('pointerdown', ev => {
|
||||
const r0 = innerWidth - fab.getBoundingClientRect().right, b0 = innerHeight - fab.getBoundingClientRect().bottom;
|
||||
let pos = {right: r0, bottom: b0};
|
||||
drag(fab, ev, (dx, dy) => { moved = true; pos = placeFab(r0 - dx, b0 - dy); if (isOpen()) placeBox(); }, () => {
|
||||
try { localStorage.setItem('lpra.ai.pos', JSON.stringify(pos)); } catch (e) { /* not kept */ }
|
||||
});
|
||||
});
|
||||
q('.ai-head').addEventListener('pointerdown', ev => {
|
||||
if (ev.target.closest('button')) return;
|
||||
const r = box.getBoundingClientRect();
|
||||
drag(box, ev, (dx, dy) => {
|
||||
box.style.left = clamp(r.left + dx, 8, innerWidth - r.width - 8) + 'px';
|
||||
box.style.top = clamp(r.top + dy, 8, innerHeight - r.height - 8) + 'px';
|
||||
}, () => {});
|
||||
});
|
||||
addEventListener('resize', () => {
|
||||
try { const p = JSON.parse(localStorage.getItem('lpra.ai.pos') || 'null'); if (p) placeFab(p.right, p.bottom); } catch (e) { /* default */ }
|
||||
if (isOpen()) placeBox();
|
||||
});
|
||||
}
|
||||
q('.ai-close').onclick = hide;
|
||||
// ---------- chats ----------
|
||||
// Several talks, kept on the server. The header names the current one;
|
||||
// its arrow opens the list: a click opens a chat, the bin deletes it.
|
||||
let chatId = 0;
|
||||
try { chatId = Number(localStorage.getItem('lpra.ai.chat')) || 0; } catch (e) { /* none */ }
|
||||
const TRASH = '<svg width="14" height="14" 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"/></svg>';
|
||||
const chatsEl = q('.ai-chats'), chatSel = q('.ai-chatsel');
|
||||
let chats = [];
|
||||
function setChat(id) {
|
||||
chatId = id || 0;
|
||||
try { localStorage.setItem('lpra.ai.chat', String(chatId)); } catch (e) { /* not kept */ }
|
||||
const c = chats.find(x => x.id === chatId);
|
||||
q('.ai-chatname').textContent = c ? c.title : 'Новый чат';
|
||||
q('.ai-chatname').title = c ? c.title : '';
|
||||
}
|
||||
async function loadChats() {
|
||||
try { chats = await fetch('/api/panel/ai/chats', {cache: 'no-store', credentials: 'same-origin'}).then(r => r.json()); } catch (e) { chats = []; }
|
||||
if (chatId && !chats.some(c => c.id === chatId)) chatId = 0;
|
||||
setChat(chatId);
|
||||
drawChats();
|
||||
}
|
||||
function drawChats() {
|
||||
const box = q('.ai-chatlist');
|
||||
box.innerHTML = '';
|
||||
if (!chats.length) box.innerHTML = '<div class="muted ai-chatnone">Пока нет чатов</div>';
|
||||
chats.forEach(c => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'ai-chatrow' + (c.id === chatId ? ' on' : '');
|
||||
row.innerHTML = '<button type="button" class="ai-chatopen"></button><button type="button" class="ai-chatdel" title="Удалить чат" aria-label="Удалить чат">' + TRASH + '</button>';
|
||||
row.querySelector('.ai-chatopen').textContent = c.title;
|
||||
row.querySelector('.ai-chatopen').title = c.title;
|
||||
row.querySelector('.ai-chatopen').onclick = () => { openChat(c.id); toggleChats(false); };
|
||||
row.querySelector('.ai-chatdel').onclick = async ev => {
|
||||
ev.stopPropagation();
|
||||
if (!await UI.confirm('Удалить чат «' + c.title + '»?', {ok: 'Удалить', danger: true})) return;
|
||||
await fetch('/api/panel/ai/chats/' + c.id + '/delete', {method: 'POST', credentials: 'same-origin'});
|
||||
if (c.id === chatId) openChat(0);
|
||||
loadChats();
|
||||
};
|
||||
box.appendChild(row);
|
||||
});
|
||||
}
|
||||
function toggleChats(on) {
|
||||
on = on == null ? chatSel.getAttribute('aria-expanded') !== 'true' : on;
|
||||
chatSel.setAttribute('aria-expanded', String(on));
|
||||
chatsEl.classList.toggle('open', on);
|
||||
chatsEl.inert = !on;
|
||||
if (on) loadChats();
|
||||
}
|
||||
chatSel.onclick = () => toggleChats();
|
||||
q('.ai-chatnew').onclick = () => { openChat(0); toggleChats(false); input.focus(); };
|
||||
document.addEventListener('pointerdown', ev => { if (chatsEl.classList.contains('open') && !chatsEl.contains(ev.target) && !chatSel.contains(ev.target)) toggleChats(false); }, true);
|
||||
// openChat shows a chat's talk (0: a new, empty one).
|
||||
async function openChat(id) {
|
||||
if (ac) ac.abort();
|
||||
setChat(id);
|
||||
drawChats();
|
||||
list.querySelectorAll('.ai-msg').forEach(m => m.remove());
|
||||
list.scrollTop = 0;
|
||||
showJump(false);
|
||||
hideFileError();
|
||||
q('.ai-hello').hidden = !!id;
|
||||
if (!id) { updateJump(); return; }
|
||||
try {
|
||||
const h = await fetch('/api/panel/ai/chats/' + id, {cache: 'no-store', credentials: 'same-origin'}).then(r => r.json());
|
||||
if (chatId !== id) return;
|
||||
h.forEach(m => {
|
||||
if (m.role === 'result') showResult(m.content);
|
||||
else { const el = add(m.role, m.content, m.think_ms); el.querySelectorAll('.ai-act').forEach(b => { b.disabled = true; }); }
|
||||
});
|
||||
if (!h.length) q('.ai-hello').hidden = false;
|
||||
list.scrollTop = list.scrollHeight;
|
||||
updateJump();
|
||||
} catch (e) { /* stays empty */ }
|
||||
}
|
||||
// The chat last open, when the window first opens.
|
||||
async function loadTalk() {
|
||||
if (loaded) return;
|
||||
loaded = true;
|
||||
await loadChats();
|
||||
openChat(chatId);
|
||||
}
|
||||
|
||||
// ---------- what the panel shows ----------
|
||||
const W = () => window.Workspace;
|
||||
function termText(p, lines) {
|
||||
const b = p.term && p.term.buffer && p.term.buffer.active;
|
||||
if (!b) return '';
|
||||
const out = [];
|
||||
for (let i = Math.max(0, b.length - lines); i < b.length; i++) {
|
||||
const l = b.getLine(i);
|
||||
if (l) out.push(l.translateToString(true));
|
||||
}
|
||||
while (out.length && !out[out.length - 1].trim()) out.pop();
|
||||
return out.join('\n');
|
||||
}
|
||||
function paneLine(p) {
|
||||
const kind = {term: 'терминал', ssh: 'SSH-терминал', files: 'файлы', sshfiles: 'файлы по SSH'}[p.kind] || p.kind;
|
||||
return kind + ' · ' + (p.tooltip ? p.tooltip() : p.title()) + ' (машина ' + (p.agent ? p.agent.name : '?') + ')';
|
||||
}
|
||||
function context() {
|
||||
const view = document.querySelector('main#app').dataset.view;
|
||||
const out = ['Страница: ' + ({machines: 'Машины', terminal: 'WorkSpace', settings: 'Настройки'}[view] || view)];
|
||||
out.push('Машины пользователя: ' + machines.map(m => m.name + ' (' + m.user + '@' + m.hostname + (m.online ? '' : ', офлайн') + ')').join('; '));
|
||||
const ws = W();
|
||||
if (!ws) return out.join('\n');
|
||||
const panes = ws.panes(), act = ws.activePane();
|
||||
out.push('Панели (' + panes.length + '):');
|
||||
panes.forEach(p => out.push((p === act ? '* ' : '- ') + paneLine(p)));
|
||||
if (act && act.term) {
|
||||
out.push('', 'Активный терминал, последние строки:', '```', termText(act, 120), '```');
|
||||
} else if (act && act.entries) {
|
||||
out.push('', 'Активная файловая панель: ' + act.cwd + (act.selected.size ? ' · выбрано: ' + [...act.selected].join(', ') : ''));
|
||||
out.push(act.entries.slice(0, 200).map(e => (e.is_dir ? e.name + '/' : e.name + ' (' + e.size + ' B)')).join('\n'));
|
||||
}
|
||||
return out.join('\n');
|
||||
}
|
||||
function where() {
|
||||
const act = W() && W().activePane();
|
||||
const name = act && act.agent ? act.agent.name : '';
|
||||
q('.ai-where').textContent = name;
|
||||
q('.ai-where').title = name;
|
||||
}
|
||||
|
||||
// ---------- machines ----------
|
||||
let machines = [];
|
||||
async function loadMachines() {
|
||||
try { machines = await fetch('/api/panel/agents/presence', {cache: 'no-store', credentials: 'same-origin'}).then(r => r.json()); } catch (e) { /* keep */ }
|
||||
return machines;
|
||||
}
|
||||
// machineOf: the machine an action names, else the one of the active pane.
|
||||
function machineOf(name) {
|
||||
const n = (name || '').trim().toLowerCase();
|
||||
if (n) return machines.find(m => m.name.toLowerCase() === n || m.id === n || m.hostname.toLowerCase() === n) || null;
|
||||
const act = W() && W().activePane();
|
||||
if (act && act.agent) return machines.find(m => m.id === act.agent.id) || {id: act.agent.id, name: act.agent.name, online: true};
|
||||
return machines.length === 1 ? machines[0] : null;
|
||||
}
|
||||
|
||||
// ---------- actions ----------
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||
const lineAt = (b, i) => { const l = b.getLine(i); return l ? l.translateToString(true) : ''; };
|
||||
// terminalFor: a terminal of machine m - the active one if it is, else any
|
||||
// open one of it, else a new tab.
|
||||
async function terminalFor(m) {
|
||||
const ws = W(), act = ws.activePane();
|
||||
if (act && act.term && !act.ssh && act.agent.id === m.id) return act;
|
||||
let t = ws.panes().find(p => p.term && !p.ssh && p.agent.id === m.id);
|
||||
if (!t) {
|
||||
if (document.querySelector('main#app').dataset.view !== 'terminal') App.go('/workspace');
|
||||
ws.open('term', {id: m.id, name: m.name, host: m.hostname, user: m.user});
|
||||
t = ws.panes().find(p => p.term && !p.ssh && p.agent.id === m.id);
|
||||
for (let i = 0; i < 40 && t && t.state !== 'ok'; i++) await sleep(250);
|
||||
await sleep(600);
|
||||
}
|
||||
return t;
|
||||
}
|
||||
// runIn sends cmd to terminal t and gives what it printed: until the
|
||||
// output has been still for 1.5 s (up to 2 minutes).
|
||||
async function runIn(t, cmd, signal) {
|
||||
const b = t.term.buffer.active;
|
||||
const start = b.baseY + b.cursorY;
|
||||
t.send(cmd.replace(/\n/g, '\r') + '\r');
|
||||
let last = '', still = 0;
|
||||
for (let ms = 0; ms < 120000 && !(signal && signal.aborted); ms += 300) {
|
||||
await sleep(300);
|
||||
const end = b.baseY + b.cursorY;
|
||||
const now = end + ':' + lineAt(b, end);
|
||||
if (now === last) { if ((still += 300) >= 1500) break; } else { still = 0; last = now; }
|
||||
}
|
||||
const lines = [];
|
||||
for (let i = start; i <= b.baseY + b.cursorY; i++) lines.push(lineAt(b, i));
|
||||
let out = lines.join('\n').replace(/\s+$/, '');
|
||||
if (out.length > 8000) out = '…' + out.slice(-8000);
|
||||
return out;
|
||||
}
|
||||
const b64 = text => { const u = new TextEncoder().encode(text); let s = ''; for (let i = 0; i < u.length; i++) s += String.fromCharCode(u[i]); return btoa(s); };
|
||||
// perform does one action with the user's rights and tells what came of it.
|
||||
async function perform(a, signal) {
|
||||
// A page by its address: its text, through the server, with «Поиск» on.
|
||||
if (a.kind === 'fetch') {
|
||||
if (!searchOn()) return 'Ошибка: чтение страниц выключено - включите плашку «Поиск»';
|
||||
const r = await fetch('/api/panel/ai/fetch', {method: 'POST', headers: {'Content-Type': 'application/json'}, credentials: 'same-origin',
|
||||
body: JSON.stringify({url: a.body})}).then(x => x.json()).catch(() => ({ok: false, error: 'нет связи'}));
|
||||
if (!r.ok) return 'Ошибка чтения ' + a.body + ': ' + r.error;
|
||||
const p = r.data;
|
||||
return 'Страница ' + p.url + (p.title ? ' - «' + p.title + '»' : '') + (p.truncated ? ' (начало)' : '') + ':\n' + p.text;
|
||||
}
|
||||
// A web search: through the server, only with «Поиск» on.
|
||||
if (a.kind === 'search') {
|
||||
if (!searchOn()) return 'Ошибка: поиск выключен - включите плашку «Поиск»';
|
||||
const r = await fetch('/api/panel/ai/search', {method: 'POST', headers: {'Content-Type': 'application/json'}, credentials: 'same-origin',
|
||||
body: JSON.stringify({q: a.body})}).then(x => x.json()).catch(() => ({ok: false, error: 'нет связи'}));
|
||||
if (!r.ok) return 'Ошибка поиска «' + a.body + '»' + (r.engine ? ' (' + r.engine + ')' : '') + ': ' + r.error;
|
||||
return 'Результаты поиска «' + a.body + '» через ' + (r.engine || 'поисковик') + ':\n' + r.data.map((x, i) => (i + 1) + '. ' + x.title + ' - ' + x.url + (x.snippet ? '\n ' + x.snippet : '')).join('\n');
|
||||
}
|
||||
await loadMachines();
|
||||
const m = machineOf(a.machine);
|
||||
if (!m) return 'Ошибка: нет машины «' + (a.machine || '?') + '». Машины: ' + machines.map(x => x.name).join(', ');
|
||||
if (m.online === false) return 'Ошибка: машина ' + m.name + ' офлайн';
|
||||
const src = new MachineSource({id: m.id, name: m.name});
|
||||
if (a.kind === 'run') {
|
||||
const t = await terminalFor(m);
|
||||
if (!t) return 'Ошибка: терминал на ' + m.name + ' не открылся';
|
||||
return 'Вывод на ' + m.name + ':\n```\n' + await runIn(t, a.body, signal) + '\n```';
|
||||
}
|
||||
if (a.kind === 'read') {
|
||||
const r = await src.op('read', {path: a.path});
|
||||
if (!r.ok) return 'Ошибка чтения ' + m.name + ':' + a.path + ': ' + r.error;
|
||||
if (r.data.binary) return m.name + ':' + a.path + ' - двоичный файл, ' + r.data.size + ' B';
|
||||
let text = new TextDecoder().decode(FileViews.b64ToBytes(r.data.content));
|
||||
if (text.length > 20000) text = text.slice(0, 20000) + '\n…';
|
||||
return 'Файл ' + m.name + ':' + a.path + (r.data.truncated ? ' (начало)' : '') + ':\n```\n' + text + '\n```';
|
||||
}
|
||||
if (a.kind === 'ls') {
|
||||
const r = await src.op('list', {path: a.path || '~'});
|
||||
if (!r.ok) return 'Ошибка: ' + r.error;
|
||||
return 'Папка ' + m.name + ':' + r.data.path + ':\n' + (r.data.entries || []).slice(0, 300)
|
||||
.map(e => (e.is_dir ? e.name + '/' : e.name + ' ' + e.size + ' B')).join('\n');
|
||||
}
|
||||
if (a.kind === 'write') {
|
||||
const r = await src.op('write', {path: a.path, data: b64(a.body + '\n')});
|
||||
return r.ok ? 'Записано: ' + m.name + ':' + a.path : 'Ошибка записи: ' + r.error;
|
||||
}
|
||||
return 'Ошибка: неизвестное действие';
|
||||
}
|
||||
// ask first for what changes something: commands and writes.
|
||||
function needsYes(a) { return a.kind === 'run' || a.kind === 'write'; }
|
||||
// ask what a command or a write is about to do: what, where, the command
|
||||
// (or the start of the text) in mono.
|
||||
function confirmAction(a) {
|
||||
const m = machineOf(a.machine), where = m ? m.name : (a.machine || 'активная машина');
|
||||
if (a.kind === 'run') return UI.confirm('Выполнить команду на {chip}?', {ok: 'Выполнить', chip: where, code: a.body});
|
||||
const lines = a.body.split('\n');
|
||||
return UI.confirm('Записать файл {chip}?', {ok: 'Записать', chip: where + ':' + a.path,
|
||||
code: lines.slice(0, 12).join('\n') + (lines.length > 12 ? '\n…' : ''),
|
||||
note: 'Файл будет заменён целиком (' + a.body.length + ' символов).'});
|
||||
}
|
||||
|
||||
// ---------- messages ----------
|
||||
// stripThink: the answer without its thinking - what is acted on and kept.
|
||||
const stripThink = t => t.replace(/^\s*(?:<think>|::думаю::)[\s\S]*?((?:<\/think>|::ответ::)\s*|$)/, '');
|
||||
const KIND = {run: 'команда', read: 'чтение', ls: 'папка', write: 'запись', search: 'поиск', fetch: 'страница'};
|
||||
const IC = d => '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">' + d + '</svg>';
|
||||
const ICONS = {run: IC('<path d="m5 7 5 5-5 5M12 17h7"/>'), read: IC('<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"/>'),
|
||||
ls: IC('<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"/>'), write: IC('<path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z"/>'),
|
||||
search: IC('<circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18"/>'),
|
||||
fetch: IC('<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"/>')};
|
||||
const LABEL = {run: 'Выполнить', read: 'Прочитать', ls: 'Открыть', write: 'Записать', search: 'Найти', fetch: 'Открыть'};
|
||||
// render: text with ``` blocks; action blocks get their button.
|
||||
// render draws an answer: its thinking, folded, above; the text with
|
||||
// Markdown; ``` blocks apart, action blocks with their button. The
|
||||
// thinking block is made once and only its words change, so it stays open
|
||||
// or shut as the user left it while the answer streams.
|
||||
// The arrow of the + menu's groups: right shut, down open.
|
||||
const CHEV = '<span class="chev">›</span>';
|
||||
function render(el, text, done) {
|
||||
let think = el.querySelector(':scope > .ai-think'), body = el.querySelector(':scope > .ai-body');
|
||||
if (!body) {
|
||||
el.innerHTML = '<div class="ai-think" hidden><button type="button" class="ai-think-h">' + CHEV + '<span class="lbl"></span></button>' +
|
||||
'<div class="ai-think-b"><div class="ai-think-in"></div></div></div><div class="ai-body"></div>';
|
||||
think = el.querySelector('.ai-think'); body = el.querySelector('.ai-body');
|
||||
think.querySelector('.ai-think-h').onclick = () => think.classList.toggle('open');
|
||||
}
|
||||
const th = /^\s*(?:<think>|::думаю::)([\s\S]*?)(<\/think>|::ответ::|$)/.exec(text);
|
||||
if (th) {
|
||||
const going = !th[2] && !done;
|
||||
if (!el._t0) el._t0 = Date.now();
|
||||
if (!going && !el._t1) el._t1 = Date.now();
|
||||
think.hidden = false;
|
||||
think.classList.toggle('going', going);
|
||||
// How long: timed here as it streams, or kept with the answer.
|
||||
const ms = el._thinkMs || (el._t1 - el._t0);
|
||||
think.querySelector('.ai-think-h .lbl').textContent = going ? 'Думаю…' : 'Думал ' + Math.max(1, Math.round(ms / 1000)) + ' сек.';
|
||||
think.querySelector('.ai-think-in').innerHTML = WSUtil.md(th[1].trim());
|
||||
text = text.slice(th[0].length);
|
||||
}
|
||||
let html = '', n = 0;
|
||||
const parts = text.split(/```/);
|
||||
parts.forEach((part, i) => {
|
||||
if (i % 2 === 0) { if (part.trim()) html += '<div class="ai-text">' + WSUtil.md(part) + '</div>'; return; }
|
||||
const m = /^([^\n]*)\n?([\s\S]*)$/.exec(part);
|
||||
const head = m[1].trim(), kind = head.split(/\s+/)[0], code = m[2].replace(/\n$/, '');
|
||||
const closed = i < parts.length - 1 || done;
|
||||
const act = LABEL[kind] && closed;
|
||||
// An action: a card with what and where on its title line, a small
|
||||
// button on the right of it, the command or text below.
|
||||
if (act) {
|
||||
const where = head.slice(kind.length).trim();
|
||||
html += '<div class="ai-code ai-action"><div class="ai-code-h"><span class="ai-kind">' + ICONS[kind] + '<b>' + KIND[kind] + '</b>' +
|
||||
(where ? '<span class="ai-where-t">' + esc(where) + '</span>' : '') + '</span>' +
|
||||
'<button type="button" class="ai-act" data-i="' + (n++) + '">' + LABEL[kind] + '</button></div>' +
|
||||
(code ? '<pre>' + esc(code) + '</pre>' : '') + '</div>';
|
||||
} else html += '<div class="ai-code"><pre>' + esc(code) + '</pre></div>';
|
||||
});
|
||||
body.innerHTML = html;
|
||||
el.dataset.text = text;
|
||||
}
|
||||
const fileExt = name => (String(name).match(/\.([^.]+)$/) || [])[1]?.toLowerCase() || '';
|
||||
const textExt = /^(txt|md|markdown|csv|tsv|json|xml|yaml|yml|log|ini|cfg|conf|html|htm|js|ts|css|sql|sh)$/;
|
||||
const officeExt = /^(docx|xlsx|ods|odt|doc)$/;
|
||||
const MEDIA_MIME = {png:'image/png',jpg:'image/jpeg',jpeg:'image/jpeg',gif:'image/gif',webp:'image/webp',bmp:'image/bmp',tif:'image/tiff',tiff:'image/tiff',mp3:'audio/mpeg',wav:'audio/wav',m4a:'audio/mp4',aac:'audio/aac',ogg:'audio/ogg',oga:'audio/ogg',flac:'audio/flac',opus:'audio/ogg',mp4:'video/mp4',m4v:'video/mp4',webm:'video/webm',mov:'video/quicktime',ogv:'video/ogg',mkv:'video/x-matroska',avi:'video/x-msvideo','3gp':'video/3gpp'};
|
||||
// attachable: a file the model can take, by its name alone.
|
||||
const attachable = name => { const ext = fileExt(name); return officeExt.test(ext) || textExt.test(ext) || ext === 'pdf' || ext === 'csv' || ext === 'tsv' || !!MEDIA_MIME[ext]; };
|
||||
const MAX_FILE = 8 * 1024 * 1024, MAX_ATTACHMENTS = 6, MAX_TOTAL = 16 * 1024 * 1024;
|
||||
function bytesToBase64(buf) {
|
||||
const bytes = new Uint8Array(buf); let bin = '';
|
||||
for (let i = 0; i < bytes.length; i += 0x8000) bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
||||
return btoa(bin);
|
||||
}
|
||||
function previewText(html) {
|
||||
return new DOMParser().parseFromString(html, 'text/html').body.textContent.replace(/\u00a0/g, ' ').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
|
||||
}
|
||||
function renderAttachments() {
|
||||
attachList.innerHTML = '';
|
||||
attachList.hidden = !attachments.length;
|
||||
sendState();
|
||||
attachments.forEach((a, i) => {
|
||||
const chip = document.createElement('span'); chip.className = 'ai-file-chip';
|
||||
const name = document.createElement('span'); name.textContent = a.name; name.title = a.name;
|
||||
const remove = document.createElement('button'); remove.type = 'button'; remove.title = 'Убрать вложение'; remove.setAttribute('aria-label', 'Убрать ' + a.name); remove.textContent = '×';
|
||||
remove.onclick = () => { attachments.splice(i, 1); renderAttachments(); };
|
||||
chip.append(name, remove); attachList.appendChild(chip);
|
||||
});
|
||||
}
|
||||
// A file that could not be attached: a red note over the field, like an
|
||||
// error toast, fading in and out.
|
||||
let fileErrTimer = 0;
|
||||
function setFileError(message) {
|
||||
fileErr.textContent = message;
|
||||
fileErr.classList.add('show');
|
||||
clearTimeout(fileErrTimer);
|
||||
fileErrTimer = setTimeout(hideFileError, 5000);
|
||||
}
|
||||
function hideFileError() { clearTimeout(fileErrTimer); fileErr.classList.remove('show'); }
|
||||
async function makeAttachments(file, name = file.name) {
|
||||
const ext = fileExt(name), type = (file.type || '').toLowerCase();
|
||||
if (file.size > MAX_FILE) throw new Error(name + ': максимум 8 МиБ на файл');
|
||||
if (officeExt.test(ext) || ext === 'pdf' || ext === 'csv' || ext === 'tsv' || textExt.test(ext) || type.startsWith('text/')) {
|
||||
const buf = await file.arrayBuffer(); let text;
|
||||
if (ext === 'docx') text = previewText(await DocView.docx(buf));
|
||||
else if (ext === 'pdf') text = await DocView.pdf(buf);
|
||||
else if (ext === 'xlsx') text = previewText(await DocView.xlsx(buf));
|
||||
else if (ext === 'ods') text = previewText(await DocView.ods(buf));
|
||||
else if (ext === 'odt') text = previewText(await DocView.odt(buf));
|
||||
else if (ext === 'doc') text = previewText(DocView.doc(buf));
|
||||
else if (ext === 'csv' || ext === 'tsv') text = previewText(DocView.csv(new TextDecoder().decode(buf), ext === 'tsv'));
|
||||
else text = new TextDecoder().decode(buf);
|
||||
if (!text) throw new Error(name + ': текст не найден');
|
||||
return [{name, mime: 'text/plain', text: text.slice(0, 120000), size: file.size}];
|
||||
}
|
||||
const fallback = MEDIA_MIME[ext] || '';
|
||||
const mime = !type || type === 'application/octet-stream' ? fallback : type;
|
||||
if (!/^image\//.test(mime) && !/^audio\//.test(mime) && !/^video\//.test(mime)) throw new Error(name + ': поддерживаются изображения, аудио, видео, текст и документы Word/Excel');
|
||||
return [{name, mime, data: bytesToBase64(await file.arrayBuffer()), size: file.size}];
|
||||
}
|
||||
async function addFiles(files, sourceIdentities = []) {
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i], sourceIdentity = sourceIdentities[i];
|
||||
if (attachments.length >= MAX_ATTACHMENTS) { setFileError('Можно прикрепить не больше ' + MAX_ATTACHMENTS + ' файлов или кадров.'); break; }
|
||||
try {
|
||||
const ready = await makeAttachments(file);
|
||||
const total = attachments.reduce((n, a) => n + (a.size || 0), 0) + ready.reduce((n, a) => n + (a.size || 0), 0);
|
||||
if (attachments.length + ready.length > MAX_ATTACHMENTS) throw new Error('Слишком много вложений (максимум ' + MAX_ATTACHMENTS + ').');
|
||||
if (total > MAX_TOTAL) throw new Error('Общий размер вложений не должен превышать 16 МиБ.');
|
||||
if (sourceIdentity) ready.forEach(attachment => Object.defineProperty(attachment, 'sourceIdentity', {value: sourceIdentity}));
|
||||
attachments.push(...ready); renderAttachments();
|
||||
} catch (e) { setFileError(e.message || 'Не удалось прочитать файл'); }
|
||||
}
|
||||
}
|
||||
const FILE_KIND = file => {
|
||||
const ext = fileExt(file.name), mime = (file.mime || '').toLowerCase();
|
||||
if (mime.startsWith('image/')) return ['image', 'Изображение'];
|
||||
if (mime.startsWith('audio/')) return ['audio', 'Аудио'];
|
||||
if (mime.startsWith('video/')) return ['video', 'Видео'];
|
||||
if (ext === 'pdf') return ['pdf', 'PDF-документ'];
|
||||
if (/^(doc|docx|odt|xls|xlsx|ods)$/.test(ext)) return ['document', 'Документ'];
|
||||
return ['text', 'Текстовый файл'];
|
||||
};
|
||||
const FILE_ICONS = {
|
||||
image: '<rect x="3" y="3" width="18" height="18" rx="3"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/>',
|
||||
audio: '<path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/>',
|
||||
video: '<rect x="3" y="5" width="14" height="14" rx="2"/><path d="m17 10 4-2v8l-4-2z"/>',
|
||||
pdf: '<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 2v6h6M8 13h8M8 17h6"/>',
|
||||
document: '<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 2v6h6M8 13h8M8 17h8"/>',
|
||||
text: '<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 2v6h6M8 13h8M8 17h8"/>'
|
||||
};
|
||||
function renderFileCards(host, files) {
|
||||
if (!files.length) return;
|
||||
const group = document.createElement('div'); group.className = 'ai-history-files';
|
||||
files.forEach(file => {
|
||||
const [kind, label] = FILE_KIND(file);
|
||||
const card = document.createElement('div'); card.className = 'ai-history-file ' + kind;
|
||||
const icon = document.createElement('span'); icon.className = 'ai-history-file-icon';
|
||||
icon.innerHTML = '<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">' + FILE_ICONS[kind] + '</svg>';
|
||||
const info = document.createElement('span'); info.className = 'ai-history-file-info';
|
||||
const name = document.createElement('span'); name.className = 'ai-history-file-name'; name.textContent = file.name; name.title = file.name;
|
||||
const meta = document.createElement('span'); meta.className = 'ai-history-file-meta';
|
||||
const ext = fileExt(file.name).toUpperCase();
|
||||
meta.textContent = label + (ext ? ' · ' + ext : '') + (file.size ? ' · ' + formatFileSize(file.size) : '');
|
||||
info.append(name, meta); card.append(icon, info); group.appendChild(card);
|
||||
});
|
||||
host.appendChild(group);
|
||||
}
|
||||
function splitStoredAttachments(text) {
|
||||
const re = /(^|\n\n)\[Вложение: ([^\r\n]+)\]/g, marks = [...text.matchAll(re)];
|
||||
if (!marks.length) return {text, files: []};
|
||||
const files = [];
|
||||
marks.forEach((mark, i) => {
|
||||
const start = mark.index + mark[0].length;
|
||||
const end = i + 1 < marks.length ? marks[i + 1].index : text.length;
|
||||
const tail = text.slice(start, end).replace(/^\n+|\n+$/g, '');
|
||||
const content = /^Содержимое файла:\n([\s\S]*)$/.exec(tail);
|
||||
files.push({name: mark[2], text: content ? content[1] : ''});
|
||||
});
|
||||
return {text: text.slice(0, marks[0].index).trimEnd(), files};
|
||||
}
|
||||
function add(role, text, thinkMs, files) {
|
||||
q('.ai-hello').hidden = true;
|
||||
const el = document.createElement('div');
|
||||
el.className = 'ai-msg ' + role;
|
||||
if (role === 'assistant') { el._thinkMs = thinkMs || 0; render(el, text, true); }
|
||||
else if (role === 'user') {
|
||||
const parsed = splitStoredAttachments(text), shownFiles = files && files.length ? files : parsed.files;
|
||||
const message = files && files.length ? text : parsed.text;
|
||||
if (message) { const copy = document.createElement('div'); copy.className = 'ai-user-copy'; copy.textContent = message; el.appendChild(copy); }
|
||||
renderFileCards(el, shownFiles);
|
||||
} else el.textContent = text;
|
||||
const follow = nearBottom();
|
||||
list.appendChild(el);
|
||||
scrollIfFollowing(follow);
|
||||
return el;
|
||||
}
|
||||
function formatFileSize(n) {
|
||||
if (n < 1024) return n + ' Б';
|
||||
if (n < 1048576) return (n / 1024).toFixed(1) + ' КБ';
|
||||
return (n / 1048576).toFixed(1) + ' МБ';
|
||||
}
|
||||
// An action's result shows folded; it reaches the talk on the server with
|
||||
// the next question (or by note, when done by hand).
|
||||
function showResult(text) {
|
||||
const el = add('result', '');
|
||||
el.innerHTML = '<details><summary>Результат</summary><pre></pre></details>';
|
||||
el.querySelector('pre').textContent = text;
|
||||
}
|
||||
list.addEventListener('click', async ev => {
|
||||
const b = ev.target.closest('.ai-act');
|
||||
if (!b || b.disabled) return;
|
||||
const msg = b.closest('.ai-msg'), a = WSUtil.parseActions(msg.dataset.text || '')[Number(b.dataset.i)];
|
||||
if (!a) return;
|
||||
b.disabled = true;
|
||||
b.textContent = '…';
|
||||
const res = await perform(a);
|
||||
b.textContent = /^Ошибка/.test(res) ? 'Ошибка' : 'Готово';
|
||||
showResult(res);
|
||||
fetch('/api/panel/ai/note', {method: 'POST', headers: {'Content-Type': 'application/json'}, credentials: 'same-origin', body: JSON.stringify({chat: chatId, content: res})});
|
||||
});
|
||||
|
||||
function responseError(status, contentType, body) {
|
||||
const html = /text\/html/i.test(contentType) || /<!doctype\s+html|<html\b|<body\b/i.test(body);
|
||||
if (html) return status === 502
|
||||
? 'Сервис ИИ временно недоступен (502 Bad Gateway). Попробуйте позже.'
|
||||
: 'Сервис ИИ вернул страницу ошибки (HTTP ' + status + '). Попробуйте позже.';
|
||||
const detail = body.trim().slice(0, 500).replace(/^\[ошибка:\s*/i, '').replace(/\]$/, '').trim();
|
||||
return detail || 'Ошибка сервиса ИИ (HTTP ' + status + ').';
|
||||
}
|
||||
// ask sends the new words (or an action's result) and shows the answer.
|
||||
async function ask(text, res, files) {
|
||||
if (text || (files && files.length)) {
|
||||
add('user', text || '', 0, files || []);
|
||||
}
|
||||
if (res) showResult(res);
|
||||
const el = add('assistant', '');
|
||||
el.classList.add('wait');
|
||||
// While the model starts: a typing mark, gone with the first words.
|
||||
el.querySelector('.ai-body').innerHTML = '<span class="ai-typing" aria-label="печатает"><i></i><i></i><i></i></span>';
|
||||
ac = new AbortController();
|
||||
send.innerHTML = STOP; send.title = 'Остановить';
|
||||
sendState();
|
||||
let answer = '';
|
||||
try {
|
||||
await loadMachines();
|
||||
const r = await fetch('/api/panel/ai/chat', {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'}, credentials: 'same-origin', signal: ac.signal,
|
||||
body: JSON.stringify({message: text || '', result: res || '', context: context(), agent: agentOn(), think: thinkOn(), search: searchOn(), chat: chatId, attachments: files || []})
|
||||
});
|
||||
// A new chat gets its id (and its name) from the answer.
|
||||
const cid = Number(r.headers.get('X-Chat-Id')) || 0;
|
||||
if (cid && cid !== chatId) {
|
||||
setChat(cid); loadChats();
|
||||
// The model names the new chat a moment later.
|
||||
setTimeout(loadChats, 4000); setTimeout(loadChats, 12000);
|
||||
}
|
||||
if (r.redirected && new URL(r.url).pathname === '/login') { location.href = '/login'; return; }
|
||||
const contentType = r.headers.get('content-type') || '';
|
||||
if (!r.ok) throw new Error(responseError(r.status, contentType, await r.text()));
|
||||
if (/text\/html/i.test(contentType)) throw new Error(responseError(r.status, contentType, ''));
|
||||
if (!r.body) throw new Error('Ошибка сервиса ИИ (HTTP ' + r.status + ').');
|
||||
const rd = r.body.getReader(), dec = new TextDecoder();
|
||||
for (;;) {
|
||||
const {done, value} = await rd.read();
|
||||
if (done) break;
|
||||
answer += dec.decode(value, {stream: true});
|
||||
el.classList.remove('wait');
|
||||
render(el, answer, false);
|
||||
updateJump();
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError') answer += (answer ? '\n' : '') + '[ошибка: ' + (e.message || 'нет связи') + ']';
|
||||
} finally {
|
||||
el.classList.remove('wait');
|
||||
render(el, answer || '[нет ответа]', true);
|
||||
updateJump();
|
||||
}
|
||||
return {el, answer: stripThink(answer)};
|
||||
}
|
||||
const pill = k => q('.ai-pill[data-mode="' + k + '"]');
|
||||
const agentOn = () => pill('agent').getAttribute('aria-pressed') === 'true';
|
||||
const thinkOn = () => pill('think').getAttribute('aria-pressed') === 'true';
|
||||
const searchOn = () => pill('search').getAttribute('aria-pressed') === 'true';
|
||||
// The modes are kept in this browser.
|
||||
box.querySelectorAll('.ai-pill').forEach(b => {
|
||||
try { b.setAttribute('aria-pressed', String(localStorage.getItem('lpra.ai.' + b.dataset.mode) === '1')); } catch (e) { /* off */ }
|
||||
b.onclick = () => {
|
||||
const on = b.getAttribute('aria-pressed') !== 'true';
|
||||
b.setAttribute('aria-pressed', String(on));
|
||||
try { localStorage.setItem('lpra.ai.' + b.dataset.mode, on ? '1' : ''); } catch (e) { /* not kept */ }
|
||||
};
|
||||
});
|
||||
// talkTurn: one question, and in agent mode the steps after it - the
|
||||
// first action of each answer done (asking first for commands and writes)
|
||||
// and its result sent back - until an answer asks for nothing.
|
||||
async function talkTurn(text, files) {
|
||||
let turn = await ask(text, null, files);
|
||||
for (let step = 0; ac && !ac.signal.aborted && step < 15; step++) {
|
||||
const a = WSUtil.parseActions(turn.answer)[0];
|
||||
if (!a) break;
|
||||
// Without «Агент» only searches go on by themselves, with «Поиск» on.
|
||||
if (!agentOn() && !((a.kind === 'search' || a.kind === 'fetch') && searchOn())) break;
|
||||
const b = turn.el.querySelector('.ai-act');
|
||||
if (needsYes(a) && !await confirmAction(a)) {
|
||||
if (b) { b.disabled = true; b.textContent = 'Отклонено'; }
|
||||
turn = await ask(null, 'Пользователь отклонил это действие. Предложи другое или закончи.');
|
||||
continue;
|
||||
}
|
||||
if (b) { b.disabled = true; b.textContent = '…'; }
|
||||
const res = await perform(a, ac.signal);
|
||||
if (b) b.textContent = /^Ошибка/.test(res) ? 'Ошибка' : 'Готово';
|
||||
if (ac.signal.aborted) { showResult(res); break; }
|
||||
turn = await ask(null, res);
|
||||
}
|
||||
ac = null;
|
||||
send.innerHTML = PLANE; send.title = 'Отправить';
|
||||
sendState();
|
||||
}
|
||||
const form = q('.ai-form-in'), attachBtn = q('.ai-attach');
|
||||
attachBtn.onclick = () => attachInput.click();
|
||||
attachInput.onchange = () => { addFiles([...attachInput.files]); attachInput.value = ''; };
|
||||
form.onsubmit = ev => {
|
||||
ev.preventDefault();
|
||||
if (ac) { ac.abort(); return; }
|
||||
const text = input.value.trim();
|
||||
if (!text && !attachments.length) return;
|
||||
const files = attachments.slice(); attachments = []; renderAttachments();
|
||||
input.value = '';
|
||||
grow();
|
||||
where();
|
||||
hideFileError();
|
||||
talkTurn(text, files);
|
||||
};
|
||||
box.addEventListener('dragover', ev => {
|
||||
if (!ev.dataTransfer || ![...ev.dataTransfer.types].includes('Files')) return;
|
||||
ev.preventDefault();
|
||||
form.classList.toggle('drag', isDropTargetAt(ev.clientX, ev.clientY));
|
||||
});
|
||||
box.addEventListener('dragleave', ev => { if (!box.contains(ev.relatedTarget)) form.classList.remove('drag'); });
|
||||
box.addEventListener('drop', ev => {
|
||||
if (!ev.dataTransfer || ![...ev.dataTransfer.types].includes('Files')) return;
|
||||
ev.preventDefault(); form.classList.remove('drag');
|
||||
if (isDropTargetAt(ev.clientX, ev.clientY)) addFiles([...ev.dataTransfer.files]);
|
||||
});
|
||||
async function addPaneFiles(pane, names) {
|
||||
if (!pane || !/files$/.test(pane.kind)) return;
|
||||
const entries = new Map((pane.entries || []).map(entry => [entry.name, entry]));
|
||||
const errors = [], paths = [];
|
||||
for (const name of names) {
|
||||
const entry = entries.get(name);
|
||||
if (!entry) { errors.push(name + ': элемент больше не отображается'); continue; }
|
||||
const path = (pane.cwd === '/' ? '/' : pane.cwd.replace(/\/$/, '') + '/') + name;
|
||||
// A folder, or a file the model cannot take: its path goes in the text.
|
||||
if (entry.is_dir || !attachable(name)) { paths.push(path); continue; }
|
||||
const identity = JSON.stringify([pane.source.id, path]);
|
||||
if (importingPaneFiles.has(identity) || attachments.some(file => file.sourceIdentity === identity)) {
|
||||
errors.push(name + ': этот файл уже прикреплён');
|
||||
continue;
|
||||
}
|
||||
importingPaneFiles.add(identity);
|
||||
try {
|
||||
const response = await fetch(pane.src.url('download', path), {credentials: 'same-origin'});
|
||||
if (!response.ok) throw new Error('HTTP ' + response.status);
|
||||
const blob = await response.blob();
|
||||
await addFiles([new File([blob], name, {type: blob.type})], [identity]);
|
||||
} catch (e) { errors.push(name + ': ' + (e.message || 'не удалось скачать')); }
|
||||
finally { importingPaneFiles.delete(identity); }
|
||||
}
|
||||
if (paths.length) insertText(paths.join(' '));
|
||||
if (errors.length) setFileError(errors.join('; '));
|
||||
}
|
||||
function isDropTargetAt(x, y) {
|
||||
if (!isOpen()) return false;
|
||||
const r = q('.ai-inputbox').getBoundingClientRect();
|
||||
return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;
|
||||
}
|
||||
window.AIChat = {
|
||||
isDropTargetAt,
|
||||
setDragging(on) { form.classList.toggle('drag', !!on); },
|
||||
dropFiles: addPaneFiles
|
||||
};
|
||||
// Send shows only when there is something to send, or an answer to stop.
|
||||
function sendState() {
|
||||
const on = !!ac || !!input.value.trim() || attachments.length > 0;
|
||||
send.classList.toggle('show', on);
|
||||
send.classList.toggle('stop', !!ac);
|
||||
send.tabIndex = on ? 0 : -1;
|
||||
}
|
||||
input.addEventListener('input', sendState);
|
||||
sendState();
|
||||
// insertText: text at the caret, set apart from its neighbours by spaces.
|
||||
function insertText(t) {
|
||||
const v = input.value, a = input.selectionStart ?? v.length, b = input.selectionEnd ?? v.length;
|
||||
const pre = a && !/\s$/.test(v.slice(0, a)) ? ' ' : '', post = b < v.length && !/^\s/.test(v.slice(b)) ? ' ' : '';
|
||||
input.value = v.slice(0, a) + pre + t + post + v.slice(b);
|
||||
const at = a + pre.length + t.length + post.length;
|
||||
input.focus();
|
||||
input.setSelectionRange(at, at);
|
||||
grow(); sendState();
|
||||
}
|
||||
// The field grows with the text, up to a few lines.
|
||||
// Measured with no scrollbar: one showing in the squeezed field would take
|
||||
// its width and wrap the last word a character too early.
|
||||
function grow() {
|
||||
input.style.overflowY = 'hidden';
|
||||
input.style.height = '40px';
|
||||
const h = input.scrollHeight + 2;
|
||||
input.style.height = Math.min(140, h) + 'px';
|
||||
input.style.overflowY = h > 140 ? 'auto' : 'hidden';
|
||||
}
|
||||
input.addEventListener('input', grow);
|
||||
input.addEventListener('keydown', ev => {
|
||||
if (ev.key === 'Enter' && !ev.shiftKey && !ev.isComposing) { ev.preventDefault(); q('.ai-form-in').requestSubmit(); }
|
||||
});
|
||||
})();
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,132 @@
|
||||
// The panel is one page: the header and three views (Машины, Терминал,
|
||||
// Настройки). Moving between them changes the address without a reload, so
|
||||
// the terminal view - and every session in it - lives as long as the tab.
|
||||
// Машины and Настройки come from the server as fragments; their forms are
|
||||
// sent from here and the fragment drawn again from the answer. A fragment's
|
||||
// script is a module registered here, mounted on the fragment's root and
|
||||
// unmounted - its timers, requests and listeners dropped - when it goes.
|
||||
(function () {
|
||||
'use strict';
|
||||
const views = {'/': 'machines', '/workspace': 'terminal', '/terminal': 'terminal', '/settings': 'settings'};
|
||||
const modules = {};
|
||||
const mounted = new Map(); // area -> {mod, scope}
|
||||
const $ = s => document.querySelector(s);
|
||||
const viewOf = path => views[path] || null;
|
||||
|
||||
function register(name, mod) { modules[name] = mod; }
|
||||
|
||||
const scope = WSUtil.scope;
|
||||
|
||||
function unmount(area) {
|
||||
const m = mounted.get(area);
|
||||
if (!m) return;
|
||||
mounted.delete(area);
|
||||
try { if (m.mod.unmount) m.mod.unmount(m.root); } finally { m.scope.end(); }
|
||||
}
|
||||
function mount(area) {
|
||||
const root = area.querySelector('[data-module]');
|
||||
const mod = root && modules[root.dataset.module];
|
||||
if (!mod) return;
|
||||
const sc = scope();
|
||||
mounted.set(area, {mod, scope: sc, root});
|
||||
mod.mount(root, sc);
|
||||
}
|
||||
// put draws html in area, the old module unmounted and the new one mounted.
|
||||
function put(area, html) {
|
||||
unmount(area);
|
||||
area.innerHTML = html;
|
||||
mount(area);
|
||||
}
|
||||
|
||||
const failHTML = msg => '<div class="wrap"><p class="err">' + msg + ' <button type="button" class="btn ghost small" data-retry>Повторить</button></p></div>';
|
||||
function withFrag(url) { const u = new URL(url, location.href); u.searchParams.set('frag', '1'); return u; }
|
||||
// A request that ended on the sign-in page: the session is over.
|
||||
function signedOut(r) { return r.redirected && new URL(r.url).pathname === '/login'; }
|
||||
|
||||
async function load(area, url) {
|
||||
let r;
|
||||
try { r = await fetch(withFrag(url), {credentials: 'same-origin', cache: 'no-store'}); } catch (e) { r = null; }
|
||||
if (r && signedOut(r)) { location.href = '/login'; return; }
|
||||
if (!r || !r.ok) {
|
||||
put(area, failHTML(r ? 'не загрузилось: ошибка ' + r.status : 'нет связи с сервером'));
|
||||
area.querySelector('[data-retry]').onclick = () => load(area, url);
|
||||
return;
|
||||
}
|
||||
put(area, await r.text());
|
||||
}
|
||||
|
||||
function show(view) {
|
||||
document.querySelectorAll('main#app > .view').forEach(v => { v.hidden = v.dataset.view !== view; });
|
||||
document.querySelectorAll('[data-nav]').forEach(a => a.classList.toggle('on', a.dataset.nav === view));
|
||||
$('main#app').dataset.view = view;
|
||||
document.dispatchEvent(new CustomEvent('app:view', {detail: view}));
|
||||
}
|
||||
// The address the shell shows; a history step that keeps it (a chat on a
|
||||
// phone closing by «назад») moves no view.
|
||||
let shown = location.pathname + location.search;
|
||||
async function go(url, push = true) {
|
||||
const u = new URL(url, location.href);
|
||||
const view = viewOf(u.pathname);
|
||||
if (!view || u.origin !== location.origin) { location.href = u.href; return; }
|
||||
if (push && u.pathname + u.search !== location.pathname + location.search) history.pushState({}, '', u.pathname + u.search + u.hash);
|
||||
shown = u.pathname + u.search;
|
||||
show(view);
|
||||
if (view !== 'terminal') await load($('.view[data-view="' + view + '"]'), u.pathname + u.search);
|
||||
}
|
||||
|
||||
// Links to a view move to it; any other link behaves as a link.
|
||||
document.addEventListener('click', ev => {
|
||||
const a = ev.target.closest('a[href]');
|
||||
if (!a || ev.defaultPrevented || ev.button !== 0 || ev.metaKey || ev.ctrlKey || ev.shiftKey || a.target || a.hasAttribute('download')) return;
|
||||
const u = new URL(a.href, location.href);
|
||||
if (u.origin === location.origin && viewOf(u.pathname)) { ev.preventDefault(); go(u.href); }
|
||||
});
|
||||
window.addEventListener('popstate', () => { if (location.pathname + location.search !== shown) go(location.href, false); });
|
||||
|
||||
// A form in a fragment is sent from here: the answer's address says what
|
||||
// to draw. /logout and forms outside the views go the usual way; a form
|
||||
// whose onsubmit said no (a confirm) is not sent.
|
||||
document.addEventListener('submit', async ev => {
|
||||
const f = ev.target;
|
||||
const area = f.closest('.view, .frag-area');
|
||||
if (!area || ev.defaultPrevented || (f.method || '').toLowerCase() !== 'post' || /\/logout$/.test(f.action)) return;
|
||||
ev.preventDefault();
|
||||
let r;
|
||||
try { r = await fetch(f.action, {method: 'POST', body: new FormData(f), credentials: 'same-origin'}); }
|
||||
catch (e) { alert('Нет связи с сервером'); return; }
|
||||
if (signedOut(r)) { location.href = '/login'; return; }
|
||||
const to = new URL(r.url);
|
||||
// From a dialog to a view (a machine deleted): the dialog closes.
|
||||
if (!area.classList.contains('view') && viewOf(to.pathname)) {
|
||||
const d = area.closest('dialog');
|
||||
if (d) d.close();
|
||||
go(to.href);
|
||||
return;
|
||||
}
|
||||
if (area.classList.contains('view')) {
|
||||
const v = viewOf(to.pathname);
|
||||
if (v && v !== area.dataset.view) { go(to.href); return; }
|
||||
history.replaceState({}, '', to.pathname + to.search + to.hash);
|
||||
}
|
||||
// The answer is the page whole (the shell): ask for its fragment.
|
||||
await load(area, to.pathname + to.search);
|
||||
if (to.hash) { const t = area.querySelector(to.hash); if (t) t.scrollIntoView(); }
|
||||
});
|
||||
|
||||
// Leaving the page drops the terminals: the browser asks while one is alive.
|
||||
window.addEventListener('beforeunload', ev => {
|
||||
if (window.Workspace && window.Workspace.hasLiveSessions()) { ev.preventDefault(); ev.returnValue = ''; }
|
||||
});
|
||||
|
||||
// start mounts what the server drew, once every script has registered.
|
||||
function start() {
|
||||
// The old address of the workspace shows as the new one.
|
||||
if (location.pathname === '/terminal') { history.replaceState({}, '', '/workspace' + location.search); shown = '/workspace' + location.search; }
|
||||
document.querySelectorAll('main#app > .view').forEach(mount);
|
||||
show({dashboard: 'machines', settings: 'settings'}[$('main#app').dataset.view] || 'terminal');
|
||||
// /agents/{id}/terminal or /files: that pane, at once.
|
||||
const o = window.APP && window.APP.open;
|
||||
if (o && window.Workspace) window.Workspace.open(o.kind, o.agent);
|
||||
}
|
||||
window.App = {go, load, register, put, start, scope};
|
||||
})();
|
||||
@@ -0,0 +1,326 @@
|
||||
// SQLite editor of the file manager: the agent opens the database and runs
|
||||
// the queries, so a database in use keeps its locks. Tables and views in
|
||||
// tabs, rows a page at a time; a click on a value edits it (Enter saves,
|
||||
// Esc cancels, Shift+Enter a new line, the ∅ button makes it NULL). Rows are
|
||||
// added and deleted, and a tab runs any SQL.
|
||||
(function () {
|
||||
'use strict';
|
||||
var PAGE = 200;
|
||||
var esc = function (s) { return String(s).replace(/[&<>"]/g, function (c) { return {'&': '&', '<': '<', '>': '>', '"': '"'}[c]; }); };
|
||||
|
||||
function cellHTML(c) {
|
||||
if (c.t === 'n') return '<i class="dbx-null">NULL</i>';
|
||||
if (c.t === 'b') return '<i class="dbx-null">BLOB ' + esc(c.v) + ' Б</i>';
|
||||
var v = c.v || '';
|
||||
return esc(v.length > 300 ? v.slice(0, 300) + '…' : v);
|
||||
}
|
||||
// The type a typed value goes as: what the value was, or what the column
|
||||
// is declared as, when the text fits it.
|
||||
function typeFor(text, was, decl) {
|
||||
var d = (decl || '').toUpperCase();
|
||||
var int = /^\s*-?\d+\s*$/.test(text), num = text.trim() !== '' && isFinite(Number(text));
|
||||
if (was === 'i' || (was !== 's' && /INT/.test(d))) return int ? 'i' : num ? 'r' : 's';
|
||||
if (was === 'r' || (was !== 's' && /REAL|FLOA|DOUB|NUM|DEC/.test(d))) return num ? 'r' : 's';
|
||||
return 's';
|
||||
}
|
||||
|
||||
function open(box, path, call, toast) {
|
||||
var S = {tables: [], cur: null, offset: 0, data: null, sel: new Set()};
|
||||
box.innerHTML =
|
||||
'<div class="dbx">' +
|
||||
'<div class="dbx-tabs" role="tablist"></div>' +
|
||||
'<div class="dbx-tools">' +
|
||||
' <span class="muted dbx-info"></span>' +
|
||||
' <span class="dbx-pager"><button type="button" class="btn ghost small icon dbx-prev" title="Назад" aria-label="Назад">‹</button>' +
|
||||
' <span class="muted dbx-range"></span><button type="button" class="btn ghost small icon dbx-next" title="Дальше" aria-label="Дальше">›</button></span>' +
|
||||
' <span class="dbx-sp"></span>' +
|
||||
' <button type="button" class="btn ghost small dbx-add">+ Строка</button>' +
|
||||
' <button type="button" class="btn danger small dbx-del" disabled>Удалить</button>' +
|
||||
'</div>' +
|
||||
'<div class="dbx-grid"></div>' +
|
||||
'<div class="dbx-sql" hidden>' +
|
||||
' <textarea class="dbx-q" spellcheck="false" placeholder="SELECT * FROM … - Ctrl+Enter выполнить"></textarea>' +
|
||||
' <div class="dbx-sqlbar"><button type="button" class="dbx-run">Выполнить</button><span class="muted dbx-sqlmsg"></span></div>' +
|
||||
' <div class="dbx-sqlres"></div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
var $ = function (s) { return box.querySelector(s); };
|
||||
|
||||
function busy(on) { box.querySelector('.dbx').classList.toggle('busy', on); }
|
||||
function req(op, args) {
|
||||
args.path = path;
|
||||
busy(true);
|
||||
return call(op, args).then(function (r) { busy(false); return r; }, function (e) { busy(false); throw e; });
|
||||
}
|
||||
|
||||
function tabs() {
|
||||
var t = $('.dbx-tabs');
|
||||
t.innerHTML = '';
|
||||
S.tables.forEach(function (x) {
|
||||
var b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.textContent = x.name;
|
||||
if (x.type === 'view') b.title = 'представление - только чтение';
|
||||
b.className = x.name === S.cur ? 'on' : '';
|
||||
b.onclick = function () { show(x.name, 0); };
|
||||
t.appendChild(b);
|
||||
});
|
||||
var q = document.createElement('button');
|
||||
q.type = 'button';
|
||||
q.textContent = 'SQL';
|
||||
q.className = 'dbx-sqltab' + (S.cur === null ? ' on' : '');
|
||||
q.onclick = sqlTab;
|
||||
t.appendChild(q);
|
||||
}
|
||||
|
||||
function show(name, offset) {
|
||||
S.cur = name; S.offset = offset; S.sel.clear();
|
||||
tabs();
|
||||
$('.dbx-sql').hidden = true;
|
||||
$('.dbx-grid').hidden = false;
|
||||
$('.dbx-tools').hidden = false;
|
||||
return req('db.rows', {table: name, offset: offset, limit: PAGE}).then(function (r) {
|
||||
if (!r.ok) { $('.dbx-grid').innerHTML = '<p class="dv-note">' + esc(r.error || 'ошибка') + '</p>'; return; }
|
||||
S.data = r.data;
|
||||
draw();
|
||||
});
|
||||
}
|
||||
|
||||
function draw() {
|
||||
var d = S.data, t = d.table, editable = !!t.key;
|
||||
// An INTEGER PRIMARY KEY column already shows the rowid.
|
||||
var showRowid = t.key === 'rowid' && !t.alias;
|
||||
$('.dbx-info').textContent = d.total + ' ' + plural(d.total) + (t.type === 'view' ? ' · представление' : !editable ? ' · только чтение' : '');
|
||||
$('.dbx-range').textContent = d.rows.length ? (d.offset + 1) + '–' + (d.offset + d.rows.length) : '';
|
||||
$('.dbx-prev').disabled = d.offset <= 0;
|
||||
$('.dbx-next').disabled = d.offset + d.rows.length >= d.total;
|
||||
$('.dbx-pager').hidden = d.total <= PAGE;
|
||||
$('.dbx-add').hidden = t.type !== 'table';
|
||||
$('.dbx-del').hidden = !editable;
|
||||
selInfo();
|
||||
var h = '<table class="dbx-t"><thead><tr>' + (editable ? '<th class="dbx-ck"><input type="checkbox" class="dbx-all" aria-label="Выбрать все"></th>' : '') +
|
||||
(showRowid ? '<th class="dbx-k">rowid</th>' : '');
|
||||
t.columns.forEach(function (c) {
|
||||
h += '<th title="' + esc(c.type + (c.pk ? ' · ключ' : '') + (c.notnull ? ' · NOT NULL' : '')) + '">' + esc(c.name) +
|
||||
(c.type ? '<small>' + esc(c.type.toLowerCase()) + '</small>' : '') + '</th>';
|
||||
});
|
||||
h += '</tr></thead><tbody>';
|
||||
d.rows.forEach(function (row, i) {
|
||||
h += '<tr data-i="' + i + '">' + (editable ? '<td class="dbx-ck"><input type="checkbox" aria-label="Выбрать строку"></td>' : '') +
|
||||
(showRowid ? '<td class="dbx-k">' + esc(row.k) + '</td>' : '');
|
||||
row.v.forEach(function (c, j) {
|
||||
h += '<td data-j="' + j + '" class="' + (c.t === 'i' || c.t === 'r' ? 'num' : '') + (editable && c.t !== 'b' ? ' ed' : '') + '">' + cellHTML(c) + '</td>';
|
||||
});
|
||||
h += '</tr>';
|
||||
});
|
||||
h += '</tbody></table>';
|
||||
if (!d.rows.length) h += '<p class="dv-note">строк нет</p>';
|
||||
$('.dbx-grid').innerHTML = h;
|
||||
$('.dbx-grid').scrollTop = 0;
|
||||
fixWidths($('.dbx-grid table'));
|
||||
}
|
||||
// The columns keep the widths they first came out at, so a value being
|
||||
// edited does not move the table about.
|
||||
function fixWidths(table) {
|
||||
if (!table) return;
|
||||
var ths = table.querySelectorAll('thead th'), ws = [], sum = 0;
|
||||
ths.forEach(function (th) { var w = Math.ceil(th.getBoundingClientRect().width); ws.push(w); sum += w; });
|
||||
ths.forEach(function (th, i) { th.style.width = ws[i] + 'px'; });
|
||||
table.style.width = sum + 'px';
|
||||
table.style.tableLayout = 'fixed';
|
||||
}
|
||||
function plural(n) {
|
||||
var m10 = n % 10, m100 = n % 100;
|
||||
return m10 === 1 && m100 !== 11 ? 'строка' : m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14) ? 'строки' : 'строк';
|
||||
}
|
||||
function selInfo() {
|
||||
var b = $('.dbx-del');
|
||||
b.disabled = !S.sel.size;
|
||||
b.textContent = S.sel.size ? 'Удалить (' + S.sel.size + ')' : 'Удалить';
|
||||
}
|
||||
|
||||
$('.dbx-prev').onclick = function () { show(S.cur, Math.max(0, S.offset - PAGE)); };
|
||||
$('.dbx-next').onclick = function () { show(S.cur, S.offset + PAGE); };
|
||||
|
||||
// ---- selecting rows ----
|
||||
$('.dbx-grid').addEventListener('change', function (ev) {
|
||||
var el = ev.target;
|
||||
if (el.classList.contains('dbx-all')) {
|
||||
box.querySelectorAll('tbody .dbx-ck input').forEach(function (c, i) { c.checked = el.checked; el.checked ? S.sel.add(i) : S.sel.delete(i); });
|
||||
} else if (el.closest('tbody .dbx-ck')) {
|
||||
var i = +el.closest('tr').dataset.i;
|
||||
el.checked ? S.sel.add(i) : S.sel.delete(i);
|
||||
}
|
||||
selInfo();
|
||||
});
|
||||
|
||||
// ---- editing a value ----
|
||||
var editing = null;
|
||||
$('.dbx-grid').addEventListener('click', function (ev) {
|
||||
var td = ev.target.closest('td.ed');
|
||||
if (!td || td.querySelector('textarea') || ev.target.closest('.dbx-edit')) return;
|
||||
edit(td);
|
||||
});
|
||||
function edit(td) {
|
||||
if (editing) finish(editing, true);
|
||||
var i = +td.parentNode.dataset.i, j = +td.dataset.j;
|
||||
var row = S.data.rows[i], cell = row.v[j], col = S.data.table.columns[j];
|
||||
var hgt = td.offsetHeight;
|
||||
td.classList.add('editing');
|
||||
td.innerHTML = '<div class="dbx-edit"><textarea spellcheck="false"></textarea><button type="button" class="dbx-nullb" title="Сделать NULL" aria-label="Сделать NULL">∅</button></div>';
|
||||
var ta = td.querySelector('textarea');
|
||||
ta.value = cell.t === 'n' ? '' : cell.v;
|
||||
ta.placeholder = cell.t === 'n' ? 'NULL' : '';
|
||||
ta.style.height = Math.max(hgt, 30) + 'px';
|
||||
ta.focus();
|
||||
ta.select();
|
||||
editing = {td: td, i: i, j: j, ta: ta, cell: cell, col: col, nul: false};
|
||||
ta.addEventListener('keydown', function (ev) {
|
||||
if (ev.key === 'Enter' && !ev.shiftKey) { ev.preventDefault(); finish(editing, true); }
|
||||
else if (ev.key === 'Escape') { ev.preventDefault(); ev.stopPropagation(); finish(editing, false); }
|
||||
else if (ev.key === 'Tab') {
|
||||
ev.preventDefault();
|
||||
var cur = editing, next = ev.shiftKey ? cur.td.previousElementSibling : cur.td.nextElementSibling;
|
||||
finish(cur, true);
|
||||
if (next && next.classList.contains('ed')) edit(next);
|
||||
}
|
||||
});
|
||||
ta.addEventListener('blur', function () { setTimeout(function () { if (editing && editing.ta === ta && document.activeElement !== td.querySelector('.dbx-nullb')) finish(editing, true); }, 120); });
|
||||
td.querySelector('.dbx-nullb').onmousedown = function (ev) { ev.preventDefault(); editing.nul = true; finish(editing, true); };
|
||||
}
|
||||
function finish(e, keep) {
|
||||
if (!e || editing !== e) return;
|
||||
editing = null;
|
||||
var text = e.ta.value, cell = e.cell;
|
||||
var value = e.nul ? {t: 'n'} : {t: typeFor(text, cell.t, e.col.type), v: text};
|
||||
var same = e.nul ? cell.t === 'n' : (cell.t !== 'n' || text !== '') && text === (cell.v || '') && (cell.t === value.t || cell.t === 's' || value.t === 's');
|
||||
if (cell.t === 'n' && !e.nul && text === '') same = true;
|
||||
e.td.classList.remove('editing');
|
||||
if (!keep || same) { e.td.innerHTML = cellHTML(cell); return; }
|
||||
e.td.innerHTML = cellHTML(value);
|
||||
e.td.classList.add('saving');
|
||||
req('db.set', {table: S.cur, key: S.data.rows[e.i].k, column: e.col.name, value: value}).then(function (r) {
|
||||
e.td.classList.remove('saving');
|
||||
if (!r.ok) {
|
||||
e.td.innerHTML = cellHTML(cell);
|
||||
flash(e.td, 'bad');
|
||||
toast(r.error || 'не сохранилось', true);
|
||||
return;
|
||||
}
|
||||
var got = r.data.value;
|
||||
S.data.rows[e.i].v[e.j] = got;
|
||||
// A changed key column moves the row's key too.
|
||||
if (r.data.key !== undefined) S.data.rows[e.i].k = r.data.key;
|
||||
e.td.innerHTML = cellHTML(got);
|
||||
e.td.className = (got.t === 'i' || got.t === 'r' ? 'num' : '') + ' ed';
|
||||
flash(e.td, 'ok');
|
||||
});
|
||||
}
|
||||
function flash(td, kind) {
|
||||
td.classList.remove('flash-ok', 'flash-bad');
|
||||
void td.offsetWidth;
|
||||
td.classList.add('flash-' + kind);
|
||||
}
|
||||
|
||||
// ---- adding and deleting rows ----
|
||||
$('.dbx-add').onclick = function () {
|
||||
if (box.querySelector('tr.dbx-new')) return;
|
||||
var t = S.data.table, tbody = box.querySelector('.dbx-t tbody');
|
||||
var tr = document.createElement('tr');
|
||||
tr.className = 'dbx-new';
|
||||
var h = (t.key ? '<td class="dbx-ck"></td>' : '') + (t.key === 'rowid' && !t.alias ? '<td class="dbx-k">новая</td>' : '');
|
||||
t.columns.forEach(function (c) {
|
||||
h += '<td><input type="text" data-c="' + esc(c.name) + '" placeholder="' + esc(c.default ? 'по умолч.: ' + c.default : c.notnull ? 'обязательно' : 'NULL') + '"></td>';
|
||||
});
|
||||
tr.innerHTML = h;
|
||||
tbody.insertBefore(tr, tbody.firstChild);
|
||||
var bar = document.createElement('tr');
|
||||
bar.className = 'dbx-newbar';
|
||||
bar.innerHTML = '<td colspan="' + (t.columns.length + 2) + '"><button type="button" class="small dbx-addok">Добавить</button> <button type="button" class="btn ghost small dbx-addno">Отмена</button> <span class="muted">пустое поле - значение по умолчанию</span></td>';
|
||||
tbody.insertBefore(bar, tr.nextSibling);
|
||||
$('.dbx-grid').scrollTop = 0;
|
||||
tr.querySelector('input').focus();
|
||||
var done = function () { tr.remove(); bar.remove(); };
|
||||
bar.querySelector('.dbx-addno').onclick = done;
|
||||
var add = function () {
|
||||
var values = {};
|
||||
tr.querySelectorAll('input').forEach(function (inp) {
|
||||
if (inp.value === '') return;
|
||||
var c = t.columns.filter(function (x) { return x.name === inp.dataset.c; })[0];
|
||||
values[c.name] = {t: typeFor(inp.value, '', c.type), v: inp.value};
|
||||
});
|
||||
req('db.add', {table: S.cur, values: values}).then(function (r) {
|
||||
if (!r.ok) { toast(r.error || 'не добавилось', true); return; }
|
||||
toast('строка добавлена');
|
||||
var total = S.data.total + 1;
|
||||
show(S.cur, Math.floor((total - 1) / PAGE) * PAGE);
|
||||
});
|
||||
};
|
||||
bar.querySelector('.dbx-addok').onclick = add;
|
||||
tr.addEventListener('keydown', function (ev) {
|
||||
if (ev.key === 'Enter') { ev.preventDefault(); add(); }
|
||||
if (ev.key === 'Escape') { ev.preventDefault(); ev.stopPropagation(); done(); }
|
||||
});
|
||||
};
|
||||
$('.dbx-del').onclick = async function () {
|
||||
var keys = [];
|
||||
S.sel.forEach(function (i) { keys.push(S.data.rows[i].k); });
|
||||
if (!keys.length || !await UI.confirm('Удалить строк: ' + keys.length + '?', {ok: 'Удалить', danger: true})) return;
|
||||
req('db.del', {table: S.cur, keys: keys}).then(function (r) {
|
||||
if (!r.ok) { toast(r.error || 'не удалилось', true); return; }
|
||||
toast('удалено строк: ' + r.data.deleted);
|
||||
show(S.cur, S.offset);
|
||||
});
|
||||
};
|
||||
|
||||
// ---- SQL ----
|
||||
function sqlTab() {
|
||||
S.cur = null;
|
||||
tabs();
|
||||
$('.dbx-grid').hidden = true;
|
||||
$('.dbx-tools').hidden = true;
|
||||
$('.dbx-sql').hidden = false;
|
||||
$('.dbx-q').focus();
|
||||
}
|
||||
function run() {
|
||||
var q = $('.dbx-q').value;
|
||||
if (!q.trim()) return;
|
||||
$('.dbx-sqlmsg').textContent = '';
|
||||
req('db.sql', {sql: q}).then(function (r) {
|
||||
var out = $('.dbx-sqlres');
|
||||
if (!r.ok) { out.innerHTML = ''; $('.dbx-sqlmsg').textContent = r.error || 'ошибка'; $('.dbx-sqlmsg').className = 'dbx-sqlmsg bad'; return; }
|
||||
$('.dbx-sqlmsg').className = 'muted dbx-sqlmsg';
|
||||
var d = r.data;
|
||||
if (d.columns) {
|
||||
$('.dbx-sqlmsg').textContent = 'строк: ' + d.rows.length + (d.more ? ' (показаны первые 500)' : '');
|
||||
var h = '<table class="dbx-t"><thead><tr>' + d.columns.map(function (c) { return '<th>' + esc(c) + '</th>'; }).join('') + '</tr></thead><tbody>';
|
||||
d.rows.forEach(function (row) {
|
||||
h += '<tr>' + row.map(function (c) { return '<td class="' + (c.t === 'i' || c.t === 'r' ? 'num' : '') + '">' + cellHTML(c) + '</td>'; }).join('') + '</tr>';
|
||||
});
|
||||
out.innerHTML = h + '</tbody></table>';
|
||||
} else {
|
||||
out.innerHTML = '';
|
||||
$('.dbx-sqlmsg').textContent = 'готово · изменено строк: ' + d.changed;
|
||||
loadTables(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
$('.dbx-run').onclick = run;
|
||||
$('.dbx-q').addEventListener('keydown', function (ev) {
|
||||
if (ev.key === 'Enter' && (ev.ctrlKey || ev.metaKey)) { ev.preventDefault(); run(); }
|
||||
});
|
||||
|
||||
function loadTables(keep) {
|
||||
return req('db.tables', {}).then(function (r) {
|
||||
if (!r.ok) return r;
|
||||
S.tables = r.data.tables;
|
||||
if (keep) { tabs(); return r; }
|
||||
if (S.tables.length) show(S.tables[0].name, 0); else sqlTab();
|
||||
return r;
|
||||
});
|
||||
}
|
||||
return loadTables(false);
|
||||
}
|
||||
|
||||
window.DBEdit = {open: open};
|
||||
})();
|
||||
@@ -0,0 +1,221 @@
|
||||
// Dragging files out of a file pane: onto another file pane or a folder in
|
||||
// it to move or copy them there (between machines and SSH hosts too, by the
|
||||
// transfer the move dialog uses), or into a terminal of the same place to
|
||||
// paste their paths. A terminal of another place refuses them, red, with
|
||||
// the reason. Held over a tab's title for half a second, the tab opens to
|
||||
// drop into. Esc or empty space cancels. Computers only.
|
||||
(function () {
|
||||
'use strict';
|
||||
if (!Workspace.desk) return;
|
||||
const FV = window.FileViews;
|
||||
const {toast, join, esc} = FV;
|
||||
let d = null, justDropped = false;
|
||||
|
||||
const box = document.createElement('div');
|
||||
box.className = 'hint drophint';
|
||||
box.hidden = true;
|
||||
document.body.appendChild(box);
|
||||
|
||||
// start: a pointerdown on a row of pane; the drag begins past 6px.
|
||||
function start(ev, pane, tr) {
|
||||
if (ev.button !== 0 || ev.target.closest('input, button, a, .act')) return;
|
||||
// Keep the file pane's pointer drag as the only drag source. Otherwise
|
||||
// the browser can start a native text-selection drag beside our ghost.
|
||||
ev.preventDefault();
|
||||
const name = tr.dataset.name;
|
||||
const names = pane.selected.has(name) ? [...pane.selected] : [name];
|
||||
d = {pane, names, x0: ev.clientX, y0: ev.clientY, id: ev.pointerId, on: false,
|
||||
item: {source: pane.source, dir: pane.cwd, paths: names.map(n => join(pane.cwd, n))}};
|
||||
}
|
||||
function begin() {
|
||||
d.on = true;
|
||||
const g = d.ghost = document.createElement('div');
|
||||
g.className = 'drag-ghost';
|
||||
g.textContent = d.names.length === 1 ? d.names[0] : d.names.length + ' элементов';
|
||||
document.body.appendChild(g);
|
||||
document.body.classList.add('dnd');
|
||||
getSelection().removeAllRanges();
|
||||
}
|
||||
function show(r, label, bad) {
|
||||
box.hidden = false;
|
||||
box.classList.toggle('bad', !!bad);
|
||||
box.style.left = r.left + 'px'; box.style.top = r.top + 'px';
|
||||
box.style.width = r.width + 'px'; box.style.height = r.height + 'px';
|
||||
box.textContent = label;
|
||||
}
|
||||
// inside: dropping folder p into itself or below it.
|
||||
const inside = (dir, p) => dir === p || dir.startsWith(p.replace(/\/+$/, '') + '/');
|
||||
|
||||
function move(ev) {
|
||||
if (!d || ev.pointerId !== d.id) return;
|
||||
if (!d.on) {
|
||||
if (Math.hypot(ev.clientX - d.x0, ev.clientY - d.y0) < 6) return;
|
||||
begin();
|
||||
}
|
||||
d.ghost.style.transform = 'translate(' + (ev.clientX + 12) + 'px,' + (ev.clientY + 10) + 'px)';
|
||||
// What the browser began to select before the drag was told apart goes.
|
||||
const sel = getSelection();
|
||||
if (sel && !sel.isCollapsed) sel.removeAllRanges();
|
||||
d.target = null;
|
||||
box.hidden = true;
|
||||
d.ghost.dataset.hint = '';
|
||||
if (window.AIChat) window.AIChat.setDragging(false);
|
||||
// Over a tab's title: it opens after half a second.
|
||||
const tab = Workspace.tabAt(ev.clientX, ev.clientY);
|
||||
if (tab !== d.overTab) {
|
||||
clearTimeout(d.tabTimer);
|
||||
d.overTab = tab;
|
||||
if (tab) d.tabTimer = setTimeout(() => { if (d && d.overTab === tab) Workspace.activate(tab); }, 500);
|
||||
}
|
||||
if (tab) { d.ghost.dataset.hint = 'открыть вкладку'; return; }
|
||||
if (window.AIChat && window.AIChat.isDropTargetAt(ev.clientX, ev.clientY)) {
|
||||
window.AIChat.setDragging(true);
|
||||
d.target = {kind: 'ai'};
|
||||
return;
|
||||
}
|
||||
const pane = Workspace.paneAt(ev.clientX, ev.clientY);
|
||||
if (!pane || !pane.dropTarget) return;
|
||||
const t = pane.dropTarget(ev.clientX, ev.clientY);
|
||||
const a = WSUtil.dropAction(d.item, t);
|
||||
const r = t.kind === 'folder' ? t.row.getBoundingClientRect() : pane.el.getBoundingClientRect();
|
||||
if (a.kind === 'deny') { show(r, a.reason, true); return; }
|
||||
if (a.kind === 'none') return;
|
||||
if (a.kind === 'transfer' && t.source.id === d.item.source.id && d.item.paths.some(p => inside(t.dir, p))) {
|
||||
show(r, 'папку не положить в саму себя', true);
|
||||
return;
|
||||
}
|
||||
show(r, a.kind === 'paste' ? 'вставить путь' : 'сюда: ' + t.dir);
|
||||
d.target = {pane, t, a};
|
||||
}
|
||||
function end(ev, cancel) {
|
||||
if (!d || (ev && ev.pointerId !== d.id)) return;
|
||||
const x = d;
|
||||
d = null;
|
||||
clearTimeout(x.tabTimer);
|
||||
if (!x.on) return;
|
||||
x.ghost.remove();
|
||||
box.hidden = true;
|
||||
document.body.classList.remove('dnd');
|
||||
if (window.AIChat) window.AIChat.setDragging(false);
|
||||
justDropped = true;
|
||||
setTimeout(() => { justDropped = false; }, 0);
|
||||
if (cancel || !x.target) return;
|
||||
if (x.target.kind === 'ai') { window.AIChat.dropFiles(x.pane, x.names); return; }
|
||||
const {pane, t, a} = x.target;
|
||||
Workspace.setActivePane(pane);
|
||||
if (a.kind === 'paste') pane.drop(x.item);
|
||||
else if (a.kind === 'transfer') ask(x.item, x.pane, t, ev.clientX, ev.clientY);
|
||||
}
|
||||
// While FileDnD owns the pointer, never let the browser start a second,
|
||||
// native drag (for example from the SVG icon or the filename under it).
|
||||
document.addEventListener('dragstart', ev => {
|
||||
if (!d) return;
|
||||
ev.preventDefault();
|
||||
ev.stopImmediatePropagation();
|
||||
}, true);
|
||||
window.addEventListener('pointermove', move);
|
||||
window.addEventListener('pointerup', ev => end(ev));
|
||||
window.addEventListener('pointercancel', ev => end(ev, true));
|
||||
window.addEventListener('keydown', ev => { if (ev.key === 'Escape' && d) end(null, true); });
|
||||
// A drop is not a click on the row it started from.
|
||||
document.addEventListener('click', ev => { if (justDropped) { ev.stopPropagation(); ev.preventDefault(); } }, true);
|
||||
|
||||
// ---------- the small window where the files were dropped ----------
|
||||
const dlg = document.createElement('div');
|
||||
dlg.className = 'dropdlg';
|
||||
dlg.hidden = true;
|
||||
dlg.innerHTML =
|
||||
'<div class="dd-what"></div><div class="dd-to muted"></div>' +
|
||||
'<label class="dd-c">если уже есть: <select class="dd-conflict">' +
|
||||
'<option value="error">сообщить</option><option value="replace">заменить</option><option value="skip">оставить</option></select></label>' +
|
||||
'<div class="mv-prog dd-prog" hidden><div class="mv-bar"><i class="dd-bar"></i></div><div class="muted dd-ptext"></div></div>' +
|
||||
'<div class="dd-btns"><button type="button" class="btn small primary dd-move">Переместить</button>' +
|
||||
'<button type="button" class="btn small dd-copy">Копировать</button>' +
|
||||
'<button type="button" class="btn small ghost dd-cancel">Отмена</button></div>';
|
||||
document.body.appendChild(dlg);
|
||||
const q = s => dlg.querySelector(s);
|
||||
let cur = null;
|
||||
|
||||
function ask(item, from, t, x, y) {
|
||||
if (cur && cur.job) { toast('дождитесь конца передачи', true); return; }
|
||||
cur = {item, from, t, job: null, stop: false};
|
||||
const n = item.paths.length;
|
||||
q('.dd-what').textContent = n === 1 ? item.paths[0].split('/').pop() : n + ' элементов';
|
||||
q('.dd-to').innerHTML = '→ ' + esc(t.source.label) + ':' + esc(t.dir);
|
||||
q('.dd-conflict').value = 'error';
|
||||
q('.dd-prog').hidden = true;
|
||||
q('.dd-move').disabled = q('.dd-copy').disabled = false;
|
||||
q('.dd-cancel').textContent = 'Отмена';
|
||||
dlg.hidden = false;
|
||||
const w = dlg.offsetWidth, h = dlg.offsetHeight;
|
||||
dlg.style.left = Math.max(8, Math.min(innerWidth - w - 8, x - w / 2)) + 'px';
|
||||
dlg.style.top = Math.max(8, Math.min(innerHeight - h - 8, y + 12)) + 'px';
|
||||
q('.dd-move').focus();
|
||||
}
|
||||
function close() { dlg.hidden = true; cur = null; }
|
||||
q('.dd-cancel').onclick = () => {
|
||||
if (cur && cur.job) { cur.stop = true; fetch('/api/panel/fs/job/' + cur.job + '/cancel', {method: 'POST'}); q('.dd-ptext').textContent = 'отмена…'; return; }
|
||||
close();
|
||||
};
|
||||
dlg.addEventListener('keydown', ev => { if (ev.key === 'Escape' && !(cur && cur.job)) close(); });
|
||||
document.addEventListener('pointerdown', ev => { if (!dlg.hidden && !dlg.contains(ev.target) && !(cur && cur.job)) close(); }, true);
|
||||
q('.dd-move').onclick = () => run(true);
|
||||
q('.dd-copy').onclick = () => run(false);
|
||||
|
||||
async function run(isMove) {
|
||||
const c = cur, {item, from, t} = c, conflict = q('.dd-conflict').value;
|
||||
q('.dd-move').disabled = q('.dd-copy').disabled = true;
|
||||
// One place (a machine, or one SSH host): copied there by its own means.
|
||||
// Between places: the transfer of the move dialog, one item at a time.
|
||||
const here = item.source.id === t.source.id;
|
||||
let done = 0, skipped = 0, err = '';
|
||||
if (!here) {
|
||||
q('.dd-prog').hidden = false;
|
||||
q('.dd-bar').style.width = '0';
|
||||
q('.dd-bar').parentNode.classList.add('wait');
|
||||
q('.dd-ptext').textContent = 'подготовка…';
|
||||
q('.dd-cancel').textContent = 'Стоп';
|
||||
}
|
||||
// Between places, all of them go as one stream: the first, and the rest
|
||||
// as "more" (the server sends one by one to older agents).
|
||||
const batch = here ? item.paths.map(p => [p]) : [item.paths];
|
||||
for (const group of batch) {
|
||||
const p = group[0];
|
||||
if (c.stop) break;
|
||||
const name = p.split('/').pop();
|
||||
let r;
|
||||
if (here) {
|
||||
r = await from.src.op('copy', {src: p, dst: join(t.dir, name), move: isMove, conflict});
|
||||
} else {
|
||||
const body = {src: p, name, dir: t.dir, conflict, move: isMove, more: group.slice(1)};
|
||||
if (t.source.kind === 'ssh') { body.to = t.pane.agent.id; body.to_conn = t.pane.src.conn; }
|
||||
else body.to = t.pane.agent.id;
|
||||
if (from.kind === 'sshfiles') body.from_conn = from.src.conn;
|
||||
// The transfer is the machine's, whichever side is an SSH host.
|
||||
r = await new MachineSource(from.agent).op('ship', body);
|
||||
if (r.ok) {
|
||||
c.job = r.data.job;
|
||||
const pre = '';
|
||||
r = await FV.followJob(c.job, (pr, text) => {
|
||||
if (pr.total > 0 || pr.done > 0) {
|
||||
q('.dd-bar').parentNode.classList.remove('wait');
|
||||
q('.dd-bar').style.width = (pr.total ? Math.min(100, pr.done / pr.total * 100) : 0).toFixed(1) + '%';
|
||||
}
|
||||
if (text) q('.dd-ptext').textContent = pre + text;
|
||||
});
|
||||
c.job = null;
|
||||
}
|
||||
}
|
||||
if (!r.ok) { err = r.error || 'ошибка'; break; }
|
||||
done += group.length;
|
||||
skipped += (r.data && r.data.skipped) || 0;
|
||||
}
|
||||
close();
|
||||
from.reload();
|
||||
if (t.pane !== from) t.pane.reload();
|
||||
if (err) toast((done ? 'сделано ' + done + ' из ' + item.paths.length + '; ' : '') + err, true);
|
||||
else toast((isMove ? 'перемещено' : 'скопировано') + (item.paths.length > 1 ? ': ' + done : '') + (skipped ? ', оставлено как было: ' + skipped : ''));
|
||||
}
|
||||
|
||||
window.FileDnD = {start, get active() { return !!(d && d.on); }};
|
||||
})();
|
||||
@@ -524,5 +524,152 @@
|
||||
return sheets(list);
|
||||
}
|
||||
|
||||
window.DocView = {docx, xlsx, ods, odt, csv, doc, sqlite, bindTabs};
|
||||
// Extract text from PDF content streams without a heavyweight renderer or OCR.
|
||||
// Flate-compressed page streams are inflated with the browser's native API.
|
||||
async function pdf(buf) {
|
||||
const input = new Uint8Array(buf);
|
||||
let source = '';
|
||||
for (let i = 0; i < input.length; i += 0x8000) source += String.fromCharCode(...input.subarray(i, i + 0x8000));
|
||||
if (!source.startsWith('%PDF-')) throw new Error('это не PDF-файл');
|
||||
const objects = /\b(\d+)\s+\d+\s+obj\b/g, pages = [];
|
||||
let inflatedBytes = 0, textBytes = 0, match;
|
||||
async function inflate(data) {
|
||||
const reader = new Blob([data]).stream().pipeThrough(new DecompressionStream('deflate')).getReader();
|
||||
const chunks = []; let total = 0;
|
||||
for (;;) {
|
||||
const part = await reader.read();
|
||||
if (part.done) break;
|
||||
total += part.value.length;
|
||||
if (total > 16 * 1024 * 1024 || inflatedBytes + total > 32 * 1024 * 1024) {
|
||||
await reader.cancel(); throw new Error('PDF stream слишком велик');
|
||||
}
|
||||
chunks.push(part.value);
|
||||
}
|
||||
const result = new Uint8Array(total); let at = 0;
|
||||
chunks.forEach(chunk => { result.set(chunk, at); at += chunk.length; });
|
||||
inflatedBytes += total;
|
||||
return result;
|
||||
}
|
||||
while ((match = objects.exec(source))) {
|
||||
const bodyStart = objects.lastIndex, possibleEnd = source.indexOf('endobj', bodyStart);
|
||||
if (possibleEnd < 0) break;
|
||||
const probe = source.slice(bodyStart, possibleEnd), stream = /\bstream\r?\n/.exec(probe);
|
||||
if (!stream) { objects.lastIndex = possibleEnd + 6; continue; }
|
||||
const dict = probe.slice(0, stream.index);
|
||||
let start = bodyStart + stream.index + stream[0].length;
|
||||
const length = /\/Length\s+(\d+)\b/.exec(dict);
|
||||
let end = length ? start + Number(length[1]) : source.indexOf('endstream', start);
|
||||
if (end < start) { objects.lastIndex = possibleEnd + 6; continue; }
|
||||
let endObj = source.indexOf('endobj', length ? end : end + 9);
|
||||
if (endObj < 0) break;
|
||||
if (/\/Subtype\s*\/Image|\/Type\s*\/(?:ObjStm|XObject|Font|FontFile)/.test(dict)) { objects.lastIndex = endObj + 6; continue; }
|
||||
let raw = source.slice(start, end);
|
||||
if (!length) raw = raw.replace(/[\r\n]+$/, '');
|
||||
let data = Uint8Array.from(raw, c => c.charCodeAt(0) & 255);
|
||||
if (dict.includes('/FlateDecode') && !dict.includes('/ASCII85Decode')) {
|
||||
try { data = await inflate(data); }
|
||||
catch (e) { objects.lastIndex = endObj + 6; continue; }
|
||||
} else if (/\/Filter\b/.test(dict)) { objects.lastIndex = endObj + 6; continue; }
|
||||
let content = '';
|
||||
for (let i = 0; i < data.length; i += 0x8000) content += String.fromCharCode(...data.subarray(i, i + 0x8000));
|
||||
if (content.includes('BT') && (content.includes('Tj') || content.includes('TJ'))) {
|
||||
pages.push(content);
|
||||
textBytes += content.length;
|
||||
if (textBytes >= 2 * 1024 * 1024) break;
|
||||
}
|
||||
objects.lastIndex = endObj + 6;
|
||||
}
|
||||
const decode = bytes => {
|
||||
if (bytes.length > 1 && bytes[0] === 0xfe && bytes[1] === 0xff) {
|
||||
const swapped = new Uint8Array(bytes.length - 2);
|
||||
for (let i = 2; i + 1 < bytes.length; i += 2) { swapped[i - 2] = bytes[i + 1]; swapped[i - 1] = bytes[i]; }
|
||||
return new TextDecoder('utf-16le').decode(swapped);
|
||||
}
|
||||
return new TextDecoder('windows-1252').decode(bytes);
|
||||
};
|
||||
function readString(s, at, literal) {
|
||||
const bytes = [];
|
||||
if (literal) {
|
||||
let depth = 1, i = at + 1;
|
||||
for (; i < s.length && depth; i++) {
|
||||
let c = s.charCodeAt(i);
|
||||
if (c === 92) {
|
||||
c = s.charCodeAt(++i);
|
||||
if (c === 110) bytes.push(10); else if (c === 114) bytes.push(13); else if (c === 116) bytes.push(9);
|
||||
else if (c === 98) bytes.push(8); else if (c === 102) bytes.push(12);
|
||||
else if (c === 10) { /* escaped line break */ }
|
||||
else if (c === 13) { if (s.charCodeAt(i + 1) === 10) i++; }
|
||||
else if (c >= 48 && c <= 55) {
|
||||
let oct = String.fromCharCode(c);
|
||||
for (let n = 0; n < 2 && /[0-7]/.test(s.charAt(i + 1)); n++) oct += s.charAt(++i);
|
||||
bytes.push(parseInt(oct, 8) & 255);
|
||||
} else bytes.push(c & 255);
|
||||
} else if (c === 40) { depth++; bytes.push(c); }
|
||||
else if (c === 41) { if (--depth) bytes.push(c); }
|
||||
else bytes.push(c & 255);
|
||||
}
|
||||
return {value: decode(Uint8Array.from(bytes)), end: i};
|
||||
}
|
||||
let hex = '', i = at + 1;
|
||||
for (; i < s.length && s.charAt(i) !== '>'; i++) if (!/\s/.test(s.charAt(i))) hex += s.charAt(i);
|
||||
if (hex.length % 2) hex += '0';
|
||||
for (let i = 0; i + 1 < hex.length; i += 2) bytes.push(parseInt(hex.slice(i, i + 2), 16));
|
||||
return {value: decode(Uint8Array.from(bytes)), end: i + 1};
|
||||
}
|
||||
function textFrom(content) {
|
||||
let out = '', inText = false, i = 0, operands = [];
|
||||
const token = () => {
|
||||
while (i < content.length && /[\x00\t\n\f\r ]/.test(content.charAt(i))) i++;
|
||||
if (content.charAt(i) === '%') { while (i < content.length && content.charAt(i) !== '\n') i++; return token(); }
|
||||
if (i >= content.length) return null;
|
||||
const c = content.charAt(i);
|
||||
if (c === '(') { const v = readString(content, i, true); i = v.end; return {type: 'str', value: v.value}; }
|
||||
if (c === '<' && content.charAt(i + 1) !== '<') { const v = readString(content, i, false); i = v.end; return {type: 'str', value: v.value}; }
|
||||
if (c === '[') {
|
||||
i++; const values = [];
|
||||
while (i < content.length) {
|
||||
while (/[\x00\t\n\f\r ]/.test(content.charAt(i))) i++;
|
||||
if (content.charAt(i) === ']') { i++; break; }
|
||||
if (content.charAt(i) === '(' || (content.charAt(i) === '<' && content.charAt(i + 1) !== '<')) {
|
||||
const v = readString(content, i, content.charAt(i) === '('); i = v.end; values.push(v.value);
|
||||
} else {
|
||||
const number = /^[+-]?(?:\d+\.?\d*|\.\d+)/.exec(content.slice(i));
|
||||
if (number) { values.push(Number(number[0])); i += number[0].length; } else i++;
|
||||
}
|
||||
}
|
||||
return {type: 'array', value: values};
|
||||
}
|
||||
if (c === '/') { i++; while (i < content.length && !/[\x00\t\n\f\r ()<>\[\]{}\/%]/.test(content.charAt(i))) i++; return {type: 'name'}; }
|
||||
const start = i++;
|
||||
while (i < content.length && !/[\x00\t\n\f\r ()<>\[\]{}\/%]/.test(content.charAt(i))) i++;
|
||||
return {type: 'word', value: content.slice(start, i)};
|
||||
};
|
||||
while (i < content.length) {
|
||||
const t = token();
|
||||
if (!t) break;
|
||||
if (t.type !== 'word' || !/^[A-Za-z*'"]+$/.test(t.value)) { operands.push(t); continue; }
|
||||
const op = t.value;
|
||||
if (op === 'BT') { inText = true; operands = []; continue; }
|
||||
if (op === 'ET') { inText = false; out += '\n'; operands = []; continue; }
|
||||
if (inText && ['Tj', 'TJ', "'", '"'].includes(op)) {
|
||||
if (op === "'" || op === '"') out += '\n';
|
||||
const v = operands[operands.length - 1];
|
||||
if (v && v.type === 'str') out += v.value;
|
||||
else if (v && v.type === 'array') out += v.value.map(x => typeof x === 'number' ? (x < -120 ? ' ' : '') : x).join('');
|
||||
} else if (inText && op === 'Tm') {
|
||||
if (out && !out.endsWith('\n')) out += '\n';
|
||||
} else if (inText && (op === 'T*' || op === 'Td' || op === 'TD')) {
|
||||
if (op === 'T*' || Number(operands[operands.length - 1]?.value) !== 0) out += '\n';
|
||||
else out += ' ';
|
||||
}
|
||||
operands = [];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const text = pages.map(textFrom).join('\n').replace(/[\t ]+\n/g, '\n').replace(/ {2,}/g, ' ').replace(/\n{3,}/g, '\n\n').trim();
|
||||
if (!text) throw new Error('в PDF не найден извлекаемый текст (сканированные страницы без OCR не читаются)');
|
||||
return text.slice(0, 120000);
|
||||
}
|
||||
|
||||
window.DocView = {docx, xlsx, ods, odt, csv, doc, pdf, sqlite, bindTabs};
|
||||
})();
|
||||
@@ -0,0 +1,32 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const zlib = require('node:zlib');
|
||||
|
||||
const runtime = {window: {}, Blob, Response, DecompressionStream, TextDecoder, Uint8Array};
|
||||
vm.createContext(runtime);
|
||||
vm.runInContext(fs.readFileSync(path.join(__dirname, 'docview.js'), 'utf8'), runtime);
|
||||
|
||||
function pdfWithStream(content, compressed = false) {
|
||||
const stream = compressed ? zlib.deflateSync(Buffer.from(content, 'latin1')) : Buffer.from(content, 'latin1');
|
||||
const filter = compressed ? ' /Filter /FlateDecode' : '';
|
||||
const prefix = `%PDF-1.4\n1 0 obj\n<< /Length ${stream.length}${filter} >>\nstream\n`;
|
||||
return Buffer.concat([Buffer.from(prefix, 'latin1'), stream, Buffer.from('\nendstream\nendobj\n%%EOF', 'latin1')]);
|
||||
}
|
||||
|
||||
test('PDF text extraction handles literal strings and escaped parentheses', async () => {
|
||||
const pdf = pdfWithStream('BT /F1 12 Tf 72 700 Td (Hello \\(PDF\\)) Tj ET');
|
||||
assert.equal(await runtime.window.DocView.pdf(pdf), 'Hello (PDF)');
|
||||
});
|
||||
|
||||
test('PDF text extraction inflates streams and keeps TJ word spacing', async () => {
|
||||
const pdf = pdfWithStream('BT /F1 12 Tf [(lightweight) -250 ( parser)] TJ ET', true);
|
||||
assert.equal(await runtime.window.DocView.pdf(pdf), 'lightweight parser');
|
||||
});
|
||||
|
||||
test('PDF text extraction reports scanned or non-text PDFs without OCR', async () => {
|
||||
const pdf = pdfWithStream('q 0 0 100 100 re f Q');
|
||||
await assert.rejects(runtime.window.DocView.pdf(pdf), /без OCR/);
|
||||
});
|
||||
@@ -0,0 +1,457 @@
|
||||
// File panes of the workspace: a folder of a machine - or of an SSH host
|
||||
// through one - as a list to go through, with uploads, new folders and files,
|
||||
// zip and delete. Each pane keeps its own folder, list and selection; the
|
||||
// dialogs (viewer, properties, move) are shared and live in fileviews.js.
|
||||
// A pane's data source does the work: MachineSource here, SSHSource for
|
||||
// files over SSH.
|
||||
(function () {
|
||||
'use strict';
|
||||
const FV = window.FileViews;
|
||||
const {toast, join, parent, fmtSize, fmtTime, esc} = FV;
|
||||
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>';
|
||||
|
||||
// Monochrome inline icons (inherit button color via currentColor).
|
||||
const SVG = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">';
|
||||
const ICON_DL = SVG + '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></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_ZIP = SVG + '<path d="M21 8v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V8"/><rect x="2" y="3" width="20" height="5" rx="1"/><path d="M10 12h4"/></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>',
|
||||
dirlink: 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"/><path d="M10 15.5l4-4M11 11.5h3v3"/></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 = e.link ? 'dirlink' : 'dir'; // a link to a folder opens like one
|
||||
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|xml)$/i.test(e.name)) k = 'code';
|
||||
else if (/\.(txt|md|log|csv|rst|cfg|conf|ovpn|env|properties)$|^(readme|license|makefile|dockerfile)/i.test(e.name)) k = 'text';
|
||||
return '<span class="ic' + (k === 'dir' || k === 'dirlink' ? ' dir' : '') + '"' + (e.link ? ' title="ссылка"' : '') + '>' + FICONS[k] + '</span>';
|
||||
}
|
||||
|
||||
// A machine's files: the panel's file API of that machine.
|
||||
class MachineSource {
|
||||
constructor(agent) { this.agent = agent; this.caps = {zip: true, unzip: true, sqlite: true}; }
|
||||
url(what, path) { return '/api/panel/agents/' + encodeURIComponent(this.agent.id) + '/fs/' + what + '?path=' + encodeURIComponent(path); }
|
||||
op(name, body) {
|
||||
return fetch('/api/panel/agents/' + encodeURIComponent(this.agent.id) + '/fs/' + name, {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body || {}), credentials: 'same-origin'
|
||||
}).then(r => r.status === 404 ? {ok: false, error: 'нет доступа к машине'} : r.json()).catch(() => ({ok: false, error: 'ошибка сети'}));
|
||||
}
|
||||
home() { return '~' + (this.agent.user || ''); }
|
||||
close() {}
|
||||
}
|
||||
|
||||
// An SSH host's files, through the machine that holds the connection.
|
||||
class SSHSource {
|
||||
// caps: zip and unzip where the host has the commands.
|
||||
constructor(agent, conn, home, caps) { this.agent = agent; this.conn = conn; this.homeDir = home || '/'; this.caps = Object.assign({zip: false, unzip: false, sqlite: false}, caps); }
|
||||
base() { return '/api/panel/agents/' + encodeURIComponent(this.agent.id) + '/sshfs/' + encodeURIComponent(this.conn) + '/'; }
|
||||
url(what, path) { return this.base() + what + '?path=' + encodeURIComponent(path); }
|
||||
op(name, body) {
|
||||
return fetch(this.base() + name, {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body || {}), credentials: 'same-origin'
|
||||
}).then(r => r.status === 404 ? {ok: false, error: 'нет доступа к машине'} : r.json()).catch(() => ({ok: false, error: 'ошибка сети'}));
|
||||
}
|
||||
home() { return this.homeDir; }
|
||||
// The pane closing lets the machine drop the connection at once.
|
||||
close() { fetch(this.base() + 'close', {method: 'POST', keepalive: true, credentials: 'same-origin'}).catch(() => {}); }
|
||||
}
|
||||
|
||||
const UP_CAP = 512 * 1024 * 1024;
|
||||
const SORT_DEF = {by: 'name', dir: 1};
|
||||
const byName = (a, b) => a.name.localeCompare(b.name, undefined, {numeric: true, sensitivity: 'base'}) || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
||||
|
||||
class FilePane {
|
||||
// o: {kind: 'agent'|'ssh', agent, src} - src made by the caller for SSH.
|
||||
constructor(o) {
|
||||
this.agent = o.agent;
|
||||
this.src = o.src || new MachineSource(o.agent);
|
||||
this.kind = o.kind === 'ssh' ? 'sshfiles' : 'files';
|
||||
this.ssh = o.ssh || null;
|
||||
this.source = o.kind === 'ssh'
|
||||
? {kind: 'ssh', id: o.agent.id + '|' + o.ssh.user + '@' + o.ssh.host + ':' + (o.ssh.port || 22), label: o.ssh.user + '@' + o.ssh.host}
|
||||
: {kind: 'agent', id: o.agent.id, label: o.agent.name};
|
||||
this.cwd = '/';
|
||||
// startDir: the folder to open first (or a promise of it), not home.
|
||||
this.startDir = o.dir || '';
|
||||
this.entries = [];
|
||||
this.selected = new Set();
|
||||
this.uploads = [];
|
||||
this.upActive = false;
|
||||
this.busyN = 0;
|
||||
this.state = 'wait';
|
||||
this.text = 'загрузка…';
|
||||
this.el = document.createElement('div');
|
||||
this.el.className = 'pane fpane';
|
||||
this.el.innerHTML =
|
||||
'<div class="pane-head"><span class="dot wait"></span>' + (this.ssh ? '<span class="badge-ssh">SSH</span>' : '') +
|
||||
'<span class="pane-title"></span><button type="button" class="pane-x" title="Закрыть" aria-label="Закрыть">' + X + '</button></div>' +
|
||||
'<div class="fpane-body">' +
|
||||
'<div class="fm-bar">' +
|
||||
' <button class="btn ghost small icon sq fp-up" type="button" title="Вверх" aria-label="Вверх"><svg width="16" height="16" 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 fp-path" value="/" spellcheck="false" autocapitalize="off" autocomplete="off" aria-label="Папка">' +
|
||||
' <button type="button" class="btn ghost small icon sq fp-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 fp-busy" title=""><span class="spinner"></span>' +
|
||||
'<svg class="fp-ring" width="18" height="18" viewBox="0 0 20 20" aria-hidden="true"><circle class="bg" cx="10" cy="10" r="8"/><circle class="fg" cx="10" cy="10" r="8" pathLength="100"/></svg>' +
|
||||
'<span class="fp-uppct"></span></button>' +
|
||||
' <button class="btn ghost small icon sq fp-refresh" type="button" 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"><path d="M21 12a9 9 0 1 1-3-6.7L21 8"/><path d="M21 3v5h-5"/></svg></button>' +
|
||||
'</div>' +
|
||||
'<div class="fm-bar">' +
|
||||
' <button class="btn small ico fp-mkdir" type="button"><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 ico fp-newfile" type="button"><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 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" class="fp-upl" multiple hidden></label>' +
|
||||
// What is done to the ticked rows: two small icons on the right,
|
||||
// their words on hover, the count beside them.
|
||||
' <span class="spacer"></span>' +
|
||||
' <span class="fp-sel"><span class="muted fp-selinfo"></span>' +
|
||||
' <button class="btn ghost small icon sq fp-zip" type="button" disabled title="В zip" aria-label="В zip">' + ICON_ZIP + '</button>' +
|
||||
' <button class="btn danger small icon sq fp-delsel" type="button" disabled title="Удалить выбранное" aria-label="Удалить выбранное">' + ICON_DEL + '</button></span>' +
|
||||
'</div>' +
|
||||
'<div class="card fp-drop" style="padding:0"><table class="fm"><thead><tr>' +
|
||||
' <th style="width:28px"><input type="checkbox" class="fp-all" aria-label="Выбрать все"></th>' +
|
||||
' <th class="srt" data-sort="name">Имя</th><th class="sz c-sz srt" data-sort="size">Размер</th>' +
|
||||
' <th class="sz c-mt srt" data-sort="mtime">Изменён</th><th class="c-act"></th>' +
|
||||
'</tr></thead><tbody class="fp-list"></tbody></table></div>' +
|
||||
'<p class="muted fp-msg" hidden></p>' +
|
||||
'</div>';
|
||||
this.q('.fp-zip').hidden = !this.src.caps.zip;
|
||||
this.bind();
|
||||
// Narrow panes (a split tab) fold like the phone layout.
|
||||
// A mid-width pane gives the time's room to the names.
|
||||
if (window.ResizeObserver) new ResizeObserver(() => {
|
||||
const w = this.el.clientWidth;
|
||||
this.el.classList.toggle('narrow', w < 520);
|
||||
this.el.classList.toggle('mid', w < 760);
|
||||
this.fitSel();
|
||||
}).observe(this.el);
|
||||
this.start();
|
||||
}
|
||||
q(sel) { return this.el.querySelector(sel); }
|
||||
|
||||
// ---- the pane interface of the workspace ----
|
||||
title() { return (this.ssh ? this.ssh.host : this.agent.name) + ' · ' + (this.cwd === '/' ? '/' : this.cwd.replace(/\/+$/, '').split('/').pop()); }
|
||||
tooltip() { return (this.ssh ? this.source.label : this.agent.name) + ':' + this.cwd; }
|
||||
live() { return false; }
|
||||
fit() {}
|
||||
focus() { this.q('.fp-path').focus({preventScroll: true}); }
|
||||
close() { this.src.close(); this.el.remove(); }
|
||||
selectedPaths() { return [...this.selected].map(n => join(this.cwd, n)); }
|
||||
// dropTarget: a folder row under the point, else this folder.
|
||||
dropTarget(x, y) {
|
||||
const el = document.elementFromPoint(x, y);
|
||||
const tr = el && el.closest && el.closest('tr[data-dir]');
|
||||
if (tr && this.el.contains(tr)) return {kind: 'folder', source: this.source, dir: join(this.cwd, tr.dataset.dir), pane: this, row: tr};
|
||||
return {kind: 'files', source: this.source, dir: this.cwd, pane: this};
|
||||
}
|
||||
|
||||
setState(state, text) {
|
||||
this.state = state; this.text = text;
|
||||
this.q('.pane-head .dot').className = 'dot ' + state;
|
||||
this.q('.pane-title').textContent = this.title();
|
||||
this.q('.pane-title').title = this.tooltip();
|
||||
if (window.Workspace) Workspace.renderPane(this);
|
||||
}
|
||||
setBusy(on) {
|
||||
this.busyN = Math.max(0, this.busyN + (on ? 1 : -1));
|
||||
this.q('.fp-busy').classList.toggle('on', this.busyN > 0);
|
||||
}
|
||||
async api(op, body) {
|
||||
this.setBusy(true);
|
||||
try { return await this.src.op(op, body); } finally { this.setBusy(false); }
|
||||
}
|
||||
// message: a line over the list - why it is empty - with a button to
|
||||
// do something about it.
|
||||
message(text, label, fn) {
|
||||
const m = this.q('.fp-msg');
|
||||
m.hidden = !text;
|
||||
m.textContent = text || '';
|
||||
if (label) {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button'; b.className = 'btn ghost small'; b.textContent = label;
|
||||
b.onclick = fn;
|
||||
m.append(' ', b);
|
||||
}
|
||||
}
|
||||
// trouble: the machine offline, the access gone or the SSH connection
|
||||
// lost - the pane says so and offers the way back.
|
||||
trouble(err) {
|
||||
// A machine that just went away first times out, then is offline.
|
||||
this.offline = /офлайн|таймаут ожидания агента/.test(err);
|
||||
if (this.offline) this.message((/офлайн/.test(err) ? 'Машина офлайн' : 'Машина не отвечает') + '. Панель обновится сама, когда машина вернётся.', 'Повторить', () => this.reload());
|
||||
else if (/нет доступа/.test(err)) this.message('Нет доступа к машине.');
|
||||
else if (this.ssh && /подключение (к хосту потеряно|закрыто)/.test(err)) this.message(err, 'Переподключиться', () => Workspace.openSSH(this.agent, 'files', this));
|
||||
else return false;
|
||||
this.entries = []; this.selected.clear(); this.render();
|
||||
this.setState('err', err);
|
||||
return true;
|
||||
}
|
||||
|
||||
async start() {
|
||||
// Start in the home folder of the machine's user (or the SSH home);
|
||||
// a listing that fails there falls back to the root.
|
||||
const dir = await this.startDir;
|
||||
this.startDir = '';
|
||||
if (dir) {
|
||||
const d = await this.api('list', {path: dir});
|
||||
if (d.ok) { this.show(d.data); return; }
|
||||
}
|
||||
const r = await this.api('list', {path: this.src.home()});
|
||||
if (r.ok) this.show(r.data); else if (!this.trouble(r.error || '')) this.go('/');
|
||||
}
|
||||
show(d) {
|
||||
this.offline = false; this.loaded = true;
|
||||
this.cwd = d.path; this.entries = d.entries || []; this.selected.clear();
|
||||
this.q('.fp-path').value = this.cwd;
|
||||
this.message('');
|
||||
this.render();
|
||||
// The status says whether it is connected, as a terminal's does; the
|
||||
// folder is in the pane itself.
|
||||
this.setState('ok', this.ssh ? 'SSH: ' + this.source.label + ':' + (this.ssh.port || 22) : 'подключено');
|
||||
}
|
||||
async go(path) {
|
||||
const r = await this.api('list', {path});
|
||||
if (!r.ok) {
|
||||
if (!this.trouble(r.error || '')) toast(r.error || 'ошибка', true);
|
||||
return false;
|
||||
}
|
||||
this.show(r.data);
|
||||
return true;
|
||||
}
|
||||
// Never listed yet (offline from the start): from the home folder.
|
||||
reload() { return this.loaded ? this.go(this.cwd) : this.start(); }
|
||||
|
||||
// Sorting: by name, size or time, folders always ahead of files. Kept in
|
||||
// this browser for each folder of each place.
|
||||
sortKey() { return 'lpra.sort:' + this.source.id + ':' + this.cwd; }
|
||||
getSort() {
|
||||
try { const v = JSON.parse(localStorage.getItem(this.sortKey())); if (v && ['name', 'size', 'mtime'].includes(v.by)) return v; } catch (e) { /* default */ }
|
||||
return SORT_DEF;
|
||||
}
|
||||
setSort(v) {
|
||||
try {
|
||||
if (v.by === SORT_DEF.by && v.dir === SORT_DEF.dir) localStorage.removeItem(this.sortKey());
|
||||
else localStorage.setItem(this.sortKey(), JSON.stringify(v));
|
||||
} catch (e) { /* not kept */ }
|
||||
}
|
||||
sorted() {
|
||||
const s = this.getSort();
|
||||
const key = s.by === 'size' ? e => e.size || 0 : s.by === 'mtime' ? e => e.mtime || 0 : null;
|
||||
return this.entries.slice().sort((a, b) => {
|
||||
if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1;
|
||||
const d = key ? key(a) - key(b) : 0;
|
||||
return (d || byName(a, b)) * s.dir;
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const tb = this.q('.fp-list');
|
||||
tb.innerHTML = '';
|
||||
this.updateSel();
|
||||
for (const e of this.sorted()) {
|
||||
const tr = document.createElement('tr');
|
||||
tr.draggable = false;
|
||||
tr.addEventListener('dragstart', ev => { ev.preventDefault(); ev.stopPropagation(); });
|
||||
if (e.is_dir) tr.dataset.dir = e.name;
|
||||
tr.dataset.name = e.name;
|
||||
const isZip = this.src.caps.unzip && !e.is_dir && /\.zip$/i.test(e.name);
|
||||
tr.innerHTML =
|
||||
'<td><input type="checkbox" class="ck" aria-label="Выбрать"></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 mt">' + fmtTime(e.mtime) + '</td>' +
|
||||
'<td><div class="act">' +
|
||||
(isZip ? '<button class="btn ghost small icon" data-act="unzip" title="Распаковать сюда">' + ICON_UNZIP + '</button>' : '') +
|
||||
(e.is_dir ? '' : '<a class="btn ghost small icon" title="Скачать" download>' + ICON_DL + '</a>') +
|
||||
'<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>' +
|
||||
'</div></td>';
|
||||
const dl = tr.querySelector('a[download]');
|
||||
if (dl) { dl.draggable = false; dl.href = this.src.url('download', join(this.cwd, e.name)); }
|
||||
tr.querySelector('.nm').onclick = () => e.is_dir ? this.go(join(this.cwd, e.name)) : FV.view(this, e);
|
||||
tr.querySelector('.ck').checked = this.selected.has(e.name);
|
||||
tr.querySelector('.ck').onchange = ev => { ev.target.checked ? this.selected.add(e.name) : this.selected.delete(e.name); this.updateSel(); };
|
||||
tr.querySelector('[data-act="del"]').onclick = () => FV.del(this, e);
|
||||
tr.querySelector('[data-act="ren"]').onclick = () => FV.move(this, e);
|
||||
const uz = tr.querySelector('[data-act="unzip"]');
|
||||
if (uz) uz.onclick = () => this.unzip(e);
|
||||
if (window.FileDnD) tr.addEventListener('pointerdown', ev => FileDnD.start(ev, this, tr));
|
||||
tb.appendChild(tr);
|
||||
}
|
||||
}
|
||||
// fitSel: the selection's buttons on the right of the blue ones; moved to
|
||||
// a line of their own, they start on the left.
|
||||
fitSel() {
|
||||
const sel = this.q('.fp-sel');
|
||||
sel.classList.remove('wrapped');
|
||||
sel.classList.toggle('wrapped', sel.offsetTop > this.q('.fp-mkdir').offsetTop + 4);
|
||||
}
|
||||
updateSel() {
|
||||
const n = this.selected.size;
|
||||
// A narrow pane (a phone) has room for the number alone.
|
||||
this.q('.fp-selinfo').textContent = n ? (this.el.classList.contains('narrow') ? n : 'Выбрано: ' + n) : '';
|
||||
this.q('.fp-selinfo').title = n ? 'Выбрано: ' + n : '';
|
||||
this.fitSel();
|
||||
this.q('.fp-zip').disabled = !n;
|
||||
this.q('.fp-delsel').disabled = !n;
|
||||
}
|
||||
async unzip(e) {
|
||||
const r = await this.api('unzip', {path: join(this.cwd, e.name), dest: this.cwd});
|
||||
r.ok ? (toast('распаковано'), this.reload()) : toast(r.error || 'ошибка', true);
|
||||
}
|
||||
|
||||
bind() {
|
||||
const q = s => this.q(s);
|
||||
q('.pane-x').onclick = ev => { ev.stopPropagation(); Workspace.closePane(this); };
|
||||
q('.pane-head').addEventListener('pointerdown', ev => { if (Workspace.desk && !ev.target.closest('.pane-x')) Workspace.startDrag(ev, 'pane', this); });
|
||||
q('.pane-head').addEventListener('contextmenu', ev => { if (window.MachineDialog) MachineDialog.menu(ev, this.agent); });
|
||||
this.el.addEventListener('pointerdown', () => Workspace.setActivePane(this), true);
|
||||
q('.fp-up').onclick = () => this.go(parent(this.cwd));
|
||||
q('.fp-refresh').onclick = () => this.reload();
|
||||
q('.fp-dirprops').onclick = () => FV.props(this, this.cwd);
|
||||
q('.fp-path').addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); this.go((q('.fp-path').value || '').trim() || '/'); }
|
||||
});
|
||||
this.el.querySelectorAll('table.fm th.srt').forEach(th => th.onclick = () => {
|
||||
const s = this.getSort(), by = th.dataset.sort;
|
||||
// A new column starts the way it is most often wanted: names A to Z,
|
||||
// sizes and times biggest and newest first.
|
||||
this.setSort(s.by === by ? {by, dir: -s.dir} : {by, dir: by === 'name' ? 1 : -1});
|
||||
this.render();
|
||||
});
|
||||
q('.fp-mkdir').onclick = async () => {
|
||||
const n = await UI.prompt('Имя новой папки:'); if (!n) return;
|
||||
const r = await this.api('mkdir', {path: join(this.cwd, n)});
|
||||
r.ok ? (toast('создано'), this.reload()) : toast(r.error || 'ошибка', true);
|
||||
};
|
||||
q('.fp-newfile').onclick = async () => {
|
||||
const n = await UI.prompt('Имя нового файла:'); if (!n) return;
|
||||
const r = await this.api('write', {path: join(this.cwd, n), data: ''});
|
||||
r.ok ? (toast('создано'), this.reload()) : toast(r.error || 'ошибка', true);
|
||||
};
|
||||
q('.fp-delsel').onclick = async () => {
|
||||
if (!await UI.confirm('Удалить выбранное (' + this.selected.size + ')?', {ok: 'Удалить', danger: true})) return;
|
||||
for (const n of this.selected) {
|
||||
const e = this.entries.find(x => x.name === n);
|
||||
await this.api(e && e.is_dir ? 'deltree' : 'delete', {path: join(this.cwd, n)});
|
||||
}
|
||||
toast('удалено'); this.reload();
|
||||
};
|
||||
q('.fp-zip').onclick = async () => {
|
||||
const name = await UI.prompt('Имя архива:', 'archive.zip'); if (!name) return;
|
||||
const r = await this.api('zip', {paths: this.selectedPaths(), dest: join(this.cwd, name)});
|
||||
r.ok ? (toast('архив создан'), this.reload()) : toast(r.error || 'ошибка', true);
|
||||
};
|
||||
q('.fp-all').onchange = ev => {
|
||||
this.selected.clear();
|
||||
if (ev.target.checked) this.entries.forEach(e => this.selected.add(e.name));
|
||||
this.el.querySelectorAll('.fp-list .ck').forEach(c => { c.checked = ev.target.checked; });
|
||||
this.updateSel();
|
||||
};
|
||||
q('.fp-busy').onclick = () => { if (this.uploads.length) FV.uploads(this); };
|
||||
q('.fp-upl').onchange = ev => { this.upload(ev.target.files); ev.target.value = ''; };
|
||||
const drop = q('.fp-drop');
|
||||
// Files from the computer dropped on the list go up here.
|
||||
const fromOS = e => e.dataTransfer && [...e.dataTransfer.types].includes('Files');
|
||||
['dragenter', 'dragover'].forEach(x => drop.addEventListener(x, e => { if (!fromOS(e)) return; e.preventDefault(); drop.classList.add('drag'); }));
|
||||
drop.addEventListener('dragleave', () => drop.classList.remove('drag'));
|
||||
drop.addEventListener('drop', e => {
|
||||
if (!fromOS(e)) return;
|
||||
e.preventDefault();
|
||||
drop.classList.remove('drag');
|
||||
if (e.dataTransfer.files.length) this.upload(e.dataTransfer.files);
|
||||
});
|
||||
}
|
||||
|
||||
// Uploads: one file at a time, each with its progress. What has gone up
|
||||
// shows as a percentage by the spinner; a click on it lists the files.
|
||||
renderUploads() {
|
||||
const ups = this.uploads;
|
||||
const pend = ups.filter(u => u.state === 'wait' || u.state === 'send' || u.state === 'write');
|
||||
const counted = ups.filter(u => !u.skip);
|
||||
const total = counted.reduce((n, u) => n + u.size, 0) || 1;
|
||||
const sent = counted.reduce((n, u) => n + (u.state === 'wait' ? 0 : u.state === 'send' ? u.size * u.frac : u.size), 0);
|
||||
const busy = this.q('.fp-busy'), pct = this.q('.fp-uppct');
|
||||
busy.classList.toggle('up', ups.length > 0);
|
||||
busy.title = ups.length ? 'Показать загружаемые файлы' : '';
|
||||
if (pend.length) pct.textContent = Math.floor(100 * sent / total) + '%' + (counted.length > 1 ? ' · ' + (counted.length - pend.length) + '/' + counted.length : '');
|
||||
// While files go up, the round mark fills as they do.
|
||||
busy.classList.toggle('uploading', pend.length > 0);
|
||||
busy.style.setProperty('--up', String(pend.length ? 100 * sent / total : 0));
|
||||
pct.classList.toggle('on', pend.length > 0);
|
||||
FV.uploadsChanged(this);
|
||||
}
|
||||
async upload(files) {
|
||||
clearTimeout(this.upDoneTimer);
|
||||
// A new batch starts a fresh list once the last one is over.
|
||||
if (!this.upActive) this.uploads.length = 0;
|
||||
const dir = this.cwd;
|
||||
const batch = [...files].map(f => f.size > UP_CAP
|
||||
? {file: f, name: f.name, size: f.size, dir, state: 'fail', frac: 0, error: 'больше 512 МБ', skip: true}
|
||||
: {file: f, name: f.name, size: f.size, dir, state: 'wait', frac: 0, error: ''});
|
||||
this.uploads.push(...batch);
|
||||
this.renderUploads();
|
||||
if (this.upActive) return;
|
||||
this.upActive = true;
|
||||
this.setBusy(true);
|
||||
let failed = 0;
|
||||
for (let u; (u = this.uploads.find(x => x.state === 'wait')); ) {
|
||||
u.state = 'send'; this.renderUploads();
|
||||
const r = await FV.sendFile(this.src, join(u.dir, u.name), u.file, frac => {
|
||||
u.frac = frac;
|
||||
if (frac >= 1 && u.state === 'send') u.state = 'write';
|
||||
this.renderUploads();
|
||||
});
|
||||
if (r.ok) u.state = 'done';
|
||||
else { u.state = 'fail'; u.error = r.error || 'ошибка'; failed++; }
|
||||
this.renderUploads();
|
||||
if (u.dir === this.cwd) this.reload();
|
||||
}
|
||||
this.setBusy(false);
|
||||
this.upActive = false;
|
||||
failed += this.uploads.filter(u => u.skip).length;
|
||||
toast(failed ? 'не загружено: ' + failed : 'загружено', !!failed);
|
||||
this.renderUploads();
|
||||
// The list stays a while for a look, then the indicator goes quiet.
|
||||
this.upDoneTimer = setTimeout(() => { if (!this.upActive) { this.uploads.length = 0; this.renderUploads(); } }, failed ? 30000 : 8000);
|
||||
}
|
||||
}
|
||||
|
||||
// Panes of a machine gone offline look every 15 s whether it is back.
|
||||
setInterval(() => {
|
||||
const off = Workspace.panes().filter(p => p.offline);
|
||||
if (!off.length || document.hidden) return;
|
||||
fetch('/api/panel/agents/presence', {cache: 'no-store', credentials: 'same-origin'}).then(r => r.json()).then(all => {
|
||||
off.forEach(p => { if (all.some(a => a.id === p.agent.id && a.online)) p.reload(); });
|
||||
}).catch(() => {});
|
||||
}, 15000);
|
||||
|
||||
window.FilePane = FilePane;
|
||||
window.MachineSource = MachineSource;
|
||||
Workspace.paneTypes.files = (agent, o) => new FilePane({kind: 'agent', agent, dir: o.dir});
|
||||
Workspace.paneTypes.sshfiles = (agent, o) => new FilePane({kind: 'ssh', agent, ssh: o.ssh, src: new SSHSource(agent, o.conn, o.home, o.caps)});
|
||||
// sshFiles connects agent to the SSH host t, then opens its files in a
|
||||
// pane; the password or key goes to the machine once and is not kept here.
|
||||
Workspace.sshFiles = (agent, t, into) => fetch('/api/panel/agents/' + encodeURIComponent(agent.id) + '/sshfs/open', {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ssh: t}), credentials: 'same-origin'
|
||||
}).then(r => r.status === 404 ? {ok: false, error: 'нет доступа к машине'} : r.json()).then(r => {
|
||||
const caps = r.ok ? {zip: !!r.data.zip, unzip: !!r.data.unzip} : null;
|
||||
if (r.ok && into) { into.src.conn = r.data.id; Object.assign(into.src.caps, caps); into.q('.fp-zip').hidden = !caps.zip; into.reload(); }
|
||||
else if (r.ok) Workspace.open('sshfiles', agent, {conn: r.data.id, home: r.data.home, caps, ssh: {host: t.host, port: t.port, user: t.user}});
|
||||
return r;
|
||||
}).catch(() => ({ok: false, error: 'ошибка сети'}));
|
||||
})();
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,88 @@
|
||||
// Порты, Статус and Изменить of a machine in a dialog over the current page:
|
||||
// the server's pages as fragments, so nothing behind is lost. They open from
|
||||
// the buttons of a machine's row, and from a menu on a tab's title or a
|
||||
// pane's title bar (a right click, or a long press on a phone). Closing the
|
||||
// dialog unmounts the fragment's module: its charts and polling stop.
|
||||
(function () {
|
||||
'use strict';
|
||||
const TITLES = {forwards: 'Порты', status: 'Статус', edit: 'Изменить'};
|
||||
const dlg = document.getElementById('machinedlg');
|
||||
const area = dlg.querySelector('.frag-area');
|
||||
const page = /^\/agents\/([^/]+)\/(forwards|status|edit)$/;
|
||||
let changed = false;
|
||||
|
||||
function open(id, what, name) {
|
||||
dlg.querySelector('.md-title').textContent = (name || id) + ' · ' + TITLES[what];
|
||||
App.put(area, '<p class="muted" style="padding:16px 18px">загрузка…</p>');
|
||||
if (!dlg.open) (window.FileViews ? FileViews.openModal(dlg) : dlg.showModal());
|
||||
App.load(area, '/agents/' + encodeURIComponent(id) + '/' + what);
|
||||
}
|
||||
dlg.addEventListener('close', () => {
|
||||
App.put(area, '');
|
||||
// A form sent in the dialog (a new name, say) shows in the list behind.
|
||||
if (changed && document.querySelector('main#app').dataset.view === 'machines') App.go('/', false);
|
||||
changed = false;
|
||||
});
|
||||
dlg.querySelector('.md-close').onclick = () => dlg.close();
|
||||
// «Изменить» drawn again after a new name: the title follows it.
|
||||
new MutationObserver(() => {
|
||||
const h = area.querySelector('[data-module="edit"] .wrap > h1');
|
||||
if (h) dlg.querySelector('.md-title').textContent = h.textContent.trim() + ' · ' + TITLES.edit;
|
||||
}).observe(area, {childList: true});
|
||||
dlg.addEventListener('submit', () => { changed = true; });
|
||||
// The fragment's own links: another page of a machine stays in the dialog,
|
||||
// anything else closes it and goes on as usual.
|
||||
area.addEventListener('click', ev => {
|
||||
const a = ev.target.closest('a[href]');
|
||||
if (!a || ev.button !== 0 || ev.metaKey || ev.ctrlKey || ev.shiftKey || a.target || a.hasAttribute('download')) return;
|
||||
const u = new URL(a.href, location.href), m = page.exec(u.pathname);
|
||||
if (u.origin !== location.origin) return;
|
||||
if (m) { ev.preventDefault(); ev.stopPropagation(); App.load(area, u.pathname + u.search); return; }
|
||||
dlg.close();
|
||||
}, true);
|
||||
document.addEventListener('click', ev => {
|
||||
const b = ev.target.closest('[data-dialog]');
|
||||
if (!b || ev.button !== 0 || ev.metaKey || ev.ctrlKey || ev.shiftKey) return;
|
||||
ev.preventDefault();
|
||||
open(b.dataset.id, b.dataset.dialog, b.dataset.name);
|
||||
});
|
||||
|
||||
// menu: Порты, Статус, Изменить of agent, by the pointer.
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'addmenu mdmenu';
|
||||
menu.hidden = true;
|
||||
document.body.appendChild(menu);
|
||||
function closeMenu() { menu.hidden = true; document.removeEventListener('pointerdown', outside, true); }
|
||||
function outside(ev) { if (!menu.contains(ev.target)) closeMenu(); }
|
||||
function showMenu(ev, agent) {
|
||||
ev.preventDefault();
|
||||
menu.innerHTML = '';
|
||||
const head = document.createElement('div');
|
||||
head.className = 'muted md-head';
|
||||
head.textContent = agent.name;
|
||||
menu.appendChild(head);
|
||||
Object.keys(TITLES).forEach(k => {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button'; b.className = 'item';
|
||||
b.innerHTML = '<span><b></b></span>';
|
||||
b.querySelector('b').textContent = TITLES[k];
|
||||
b.onclick = () => { closeMenu(); open(agent.id, k, agent.name); };
|
||||
menu.appendChild(b);
|
||||
});
|
||||
menu.hidden = false;
|
||||
const m = menu.getBoundingClientRect();
|
||||
menu.style.left = Math.max(8, Math.min(innerWidth - m.width - 8, ev.clientX)) + 'px';
|
||||
menu.style.top = Math.max(8, Math.min(innerHeight - m.height - 8, ev.clientY)) + 'px';
|
||||
setTimeout(() => document.addEventListener('pointerdown', outside, true), 0);
|
||||
}
|
||||
document.addEventListener('keydown', ev => { if (ev.key === 'Escape' && !menu.hidden) closeMenu(); });
|
||||
|
||||
// A machine page asked for by its address: the machines view with it open.
|
||||
const m = window.APP && window.APP.dialog && page.exec(window.APP.dialog);
|
||||
if (m) {
|
||||
const row = document.querySelector('[data-open][data-id="' + CSS.escape(m[1]) + '"]');
|
||||
history.replaceState({}, '', '/');
|
||||
open(m[1], m[2], row ? row.dataset.name : m[1]);
|
||||
}
|
||||
window.MachineDialog = {open, menu: showMenu};
|
||||
})();
|
||||
@@ -0,0 +1,327 @@
|
||||
// Scripts of the pages the shell shows as fragments, as modules: mount(root,
|
||||
// S) runs when the fragment is put in place, with S the module's scope -
|
||||
// timers, requests and listeners that end when it is replaced or closed.
|
||||
(function () {
|
||||
'use strict';
|
||||
const 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);
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text; ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0';
|
||||
document.body.appendChild(ta); ta.select();
|
||||
const ok = document.execCommand('copy'); ta.remove();
|
||||
return ok ? Promise.resolve() : Promise.reject(new Error('нет доступа к буферу'));
|
||||
}
|
||||
|
||||
App.register('settings', {
|
||||
mount(root) {
|
||||
root.querySelectorAll('.copybox .copy').forEach(b => {
|
||||
const icon = b.innerHTML;
|
||||
b.onclick = () => copy(root.querySelector('#' + b.dataset.copy).textContent.trim()).then(() => {
|
||||
b.innerHTML = CHECK; b.classList.add('done'); b.title = 'Скопировано';
|
||||
setTimeout(() => { b.innerHTML = icon; b.classList.remove('done'); b.title = 'Скопировать'; }, 1500);
|
||||
}, e => alert('Не скопировалось: ' + e.message));
|
||||
});
|
||||
if (window.UsersTable) window.UsersTable.mount(root);
|
||||
}
|
||||
});
|
||||
|
||||
// The machines list keeps its status column live; nothing is asked while
|
||||
// the tab is hidden.
|
||||
App.register('dashboard', {
|
||||
mount(root, S) {
|
||||
// Terminal and files buttons open a tab in the workspace, no reload.
|
||||
S.on(root, 'click', function (ev) {
|
||||
var b = ev.target.closest('[data-open]');
|
||||
if (!b || !window.Workspace || !Workspace.paneTypes[b.dataset.open]) return;
|
||||
ev.preventDefault();
|
||||
Workspace.open(b.dataset.open, {id: b.dataset.id, name: b.dataset.name, host: b.dataset.host, user: b.dataset.user});
|
||||
});
|
||||
var setTimeout = S.timeout, clearTimeout = S.clear, fetch = S.fetch;
|
||||
|
||||
var rows = {};
|
||||
root.querySelectorAll('tr[data-agent]').forEach(function (tr) { rows[tr.dataset.agent] = tr; });
|
||||
var timer = null, busy = false;
|
||||
function schedule() {
|
||||
clearTimeout(timer);
|
||||
if (!document.hidden) timer = setTimeout(load, 5000);
|
||||
}
|
||||
function load() {
|
||||
if (busy || document.hidden) return;
|
||||
busy = true;
|
||||
fetch('/api/panel/agents/presence', {credentials: 'same-origin'})
|
||||
.then(function (r) { return r.ok ? r.json() : []; })
|
||||
.then(function (list) {
|
||||
list.forEach(function (a) {
|
||||
var tr = rows[a.id];
|
||||
if (!tr) return;
|
||||
tr.querySelector('.dot').className = 'dot ' + (a.online ? 'on' : 'off');
|
||||
tr.querySelector('.state').textContent = a.online ? 'online' : 'offline';
|
||||
tr.querySelector('.seen').textContent = a.seen;
|
||||
});
|
||||
})
|
||||
.catch(function () {})
|
||||
.then(function () { busy = false; schedule(); });
|
||||
}
|
||||
S.on(document, 'visibilitychange', function () {
|
||||
if (document.hidden) clearTimeout(timer); else load();
|
||||
});
|
||||
if (Object.keys(rows).length) schedule();
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
// Ports: the note under the mode follows the chosen mode.
|
||||
App.register('forwards', {
|
||||
mount(root) {
|
||||
var notes = {
|
||||
http: 'Трафик идёт через порт панели ' + root.dataset.panelPort + ' (long-poll). Порт ' + root.dataset.tunnelPort + ' не требуется.',
|
||||
tcp: 'Требуется открытый порт ' + root.dataset.tunnelPort + '/tcp на хосте. Самая низкая задержка.',
|
||||
udp: 'Требуется открытый порт ' + root.dataset.tunnelPort + '/udp на хосте.'
|
||||
};
|
||||
var sel = root.querySelector('#proto'), note = root.querySelector('#note');
|
||||
if (!sel || !note) return;
|
||||
function upd() { note.textContent = notes[sel.value] || ''; }
|
||||
sel.onchange = upd; upd();
|
||||
}
|
||||
});
|
||||
|
||||
// Status: meters, live speed and the speed test of a machine.
|
||||
App.register('status', {
|
||||
mount(root, S) {
|
||||
// Timers, requests and listeners end with the page.
|
||||
var setTimeout = S.timeout, setInterval = S.interval, clearTimeout = S.clear, clearInterval = S.clear, fetch = S.fetch;
|
||||
|
||||
var base = '/api/panel/agents/' + encodeURIComponent(root.dataset.agent);
|
||||
var $ = function (id) { return root.querySelector('#' + id); };
|
||||
var errBox = $('err');
|
||||
|
||||
function size(n) {
|
||||
var u = ['Б', 'КБ', 'МБ', 'ГБ', 'ТБ'], i = 0;
|
||||
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
|
||||
return n.toFixed(n < 10 && i > 0 ? 1 : 0) + ' ' + u[i];
|
||||
}
|
||||
function rateParts(bps) {
|
||||
var u = ['бит/с', 'Кбит/с', 'Мбит/с', 'Гбит/с'], i = 0;
|
||||
while (bps >= 1000 && i < u.length - 1) { bps /= 1000; i++; }
|
||||
return [bps.toFixed(bps < 100 && i > 0 ? 1 : 0), u[i]];
|
||||
}
|
||||
function rate(bps) { return rateParts(bps).join(' '); }
|
||||
function meter(name, pct, sub) {
|
||||
pct = Math.max(0, Math.min(100, pct));
|
||||
$(name + '-val').textContent = Math.round(pct) + '%';
|
||||
var bar = $(name + '-bar');
|
||||
bar.style.transform = 'scaleX(' + pct / 100 + ')';
|
||||
bar.className = pct >= 90 ? 'crit' : pct >= 75 ? 'warn' : '';
|
||||
$(name + '-sub').textContent = sub;
|
||||
}
|
||||
function showErr(msg) {
|
||||
errBox.textContent = msg || '';
|
||||
errBox.hidden = !msg;
|
||||
}
|
||||
function cores(n) {
|
||||
var d = n % 10, h = n % 100;
|
||||
return n + (d === 1 && h !== 11 ? ' ядро' : d >= 2 && d <= 4 && (h < 12 || h > 14) ? ' ядра' : ' ядер');
|
||||
}
|
||||
|
||||
// One request at a time, and none while the tab is hidden: the client is
|
||||
// sampled only for a page somebody is looking at.
|
||||
var timer = null, busy = false;
|
||||
function schedule(ms) {
|
||||
clearTimeout(timer);
|
||||
if (!document.hidden) timer = setTimeout(load, ms);
|
||||
}
|
||||
function load() {
|
||||
if (busy || document.hidden) return;
|
||||
busy = true;
|
||||
var next = 3000;
|
||||
fetch(base + '/stat', {credentials: 'same-origin'})
|
||||
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
|
||||
.then(function (res) {
|
||||
if (!res.ok) { showErr(res.error || 'ошибка'); next = 10000; return; }
|
||||
showErr('');
|
||||
var d = res.data;
|
||||
meter('cpu', d.cpu, cores(d.ncpu));
|
||||
meter('mem', d.mem_total ? 100 * d.mem_used / d.mem_total : 0,
|
||||
'занято ' + size(d.mem_used) + ' из ' + size(d.mem_total) + ' · свободно ' + size(d.mem_total - d.mem_used));
|
||||
meter('disk', d.disk_total ? 100 * (d.disk_total - d.disk_free) / d.disk_total : 0,
|
||||
'свободно ' + size(d.disk_free) + ' из ' + size(d.disk_total));
|
||||
})
|
||||
.catch(function (e) { showErr('Нет связи с панелью: ' + e.message); next = 10000; })
|
||||
.then(function () { busy = false; schedule(next); });
|
||||
}
|
||||
S.on(document, 'visibilitychange', function () {
|
||||
if (document.hidden) clearTimeout(timer); else load();
|
||||
});
|
||||
load();
|
||||
|
||||
var btn = $('speed-btn'), note = $('speed-note');
|
||||
function json(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }
|
||||
function post(path) { return fetch(base + path, {method: 'POST', credentials: 'same-origin'}).then(json); }
|
||||
|
||||
// The browser's round trip to the panel: the first request may pay for a
|
||||
// new connection, so keep the best.
|
||||
function panelPing() {
|
||||
var best = Infinity, left = 4;
|
||||
function one() {
|
||||
var t = performance.now();
|
||||
return fetch('/api/panel/ping', {credentials: 'same-origin', cache: 'no-store'})
|
||||
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.arrayBuffer(); })
|
||||
.then(function () {
|
||||
best = Math.min(best, performance.now() - t);
|
||||
return --left > 0 ? one() : best;
|
||||
});
|
||||
}
|
||||
return one();
|
||||
}
|
||||
// Keeps the client's download fed until stopped or the panel says the
|
||||
// client has read enough; the pieces grow while they go through quickly.
|
||||
function feed(stop) {
|
||||
var block = new Uint8Array(65536), n = 4;
|
||||
crypto.getRandomValues(block);
|
||||
function one() {
|
||||
var parts = [], t = performance.now();
|
||||
for (var i = 0; i < n; i++) parts.push(block);
|
||||
return fetch(base + '/speed/down', {method: 'POST', body: new Blob(parts), credentials: 'same-origin', signal: stop.signal})
|
||||
.then(function (r) {
|
||||
if (r.status !== 204) return;
|
||||
if (performance.now() - t < 500 && n < 256) n *= 2;
|
||||
return one();
|
||||
});
|
||||
}
|
||||
one().catch(function () {});
|
||||
}
|
||||
// Reads what the client sends and times it from the first byte. It stops by
|
||||
// itself a little after the client's window, so bytes still queued on a slow
|
||||
// link don't hold the result back. st.n is the count so far, for the live
|
||||
// reading.
|
||||
function drain(st, onFirst) {
|
||||
var ac = new AbortController(), last = 0;
|
||||
st.stop = function () { ac.abort(); };
|
||||
function done() { return st.start && last > st.start ? st.n * 8000 / (last - st.start) : 0; }
|
||||
return fetch(base + '/speed/up', {credentials: 'same-origin', cache: 'no-store', signal: ac.signal}).then(function (r) {
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
var rd = r.body.getReader();
|
||||
function pump() {
|
||||
return rd.read().then(function (c) {
|
||||
if (c.done) return done();
|
||||
last = performance.now();
|
||||
if (!st.start) { st.start = last; setTimeout(st.stop, Number(root.dataset.relayUpMs)); onFirst(); }
|
||||
else st.n += c.value.length;
|
||||
return pump();
|
||||
});
|
||||
}
|
||||
return pump();
|
||||
}).catch(function (e) { if (st.start) return done(); throw e; });
|
||||
}
|
||||
// The test through the browser: the client's download is what this page
|
||||
// uploads, and its upload is timed here, where it arrives.
|
||||
function relayTest(st) {
|
||||
return panelPing().then(function (ping) {
|
||||
return post('/speed/relay').then(function (res) {
|
||||
if (!res.ok) return res;
|
||||
var stop = new AbortController();
|
||||
var up = drain(st, function () { stop.abort(); });
|
||||
up.catch(function () {});
|
||||
feed(stop);
|
||||
return post('/speed?relay=1').then(function (res) {
|
||||
stop.abort();
|
||||
// No upload under way by now: none is coming.
|
||||
if (!res.ok || !st.start) { st.stop(); }
|
||||
if (!res.ok) return res;
|
||||
return up.catch(function () { return 0; }).then(function (bps) {
|
||||
res.data.ping_ms += ping;
|
||||
if (bps) res.data.up_bps = bps;
|
||||
return res;
|
||||
});
|
||||
}, function (e) { stop.abort(); st.stop(); throw e; });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// The live reading: the rate over the last second, from the byte counts the
|
||||
// panel keeps for the running test - or, for the client's upload in a test
|
||||
// through the browser, from what has arrived here.
|
||||
var ARC = 251.4, lv = null;
|
||||
function liveStart(relay, st) {
|
||||
lv = {relay: relay, st: st, phase: '', pts: [], hist: [], busy: false};
|
||||
$('live').hidden = false;
|
||||
liveShow('ping', 0);
|
||||
lv.timer = setInterval(liveTick, 200);
|
||||
}
|
||||
function liveStop() {
|
||||
if (!lv) return;
|
||||
clearInterval(lv.timer);
|
||||
lv = null;
|
||||
$('live').hidden = true;
|
||||
}
|
||||
function liveTick() {
|
||||
var cur = lv;
|
||||
if (cur.relay && cur.st.start) { livePoint(cur, 'up', cur.st.n, performance.now()); return; }
|
||||
if (cur.busy) return;
|
||||
cur.busy = true;
|
||||
fetch(base + '/speed/progress', {credentials: 'same-origin', cache: 'no-store'})
|
||||
.then(json)
|
||||
.then(function (d) {
|
||||
if (cur !== lv || !d.phase || (cur.relay && cur.st.start)) return;
|
||||
livePoint(cur, d.phase, d.phase === 'up' ? d.up : d.down, d.ms);
|
||||
})
|
||||
.catch(function () {})
|
||||
.then(function () { cur.busy = false; });
|
||||
}
|
||||
function livePoint(cur, phase, bytes, ms) {
|
||||
if (phase !== cur.phase) { cur.phase = phase; cur.pts = []; cur.hist = []; }
|
||||
if (phase === 'ping') { liveShow(phase, 0); return; }
|
||||
var pts = cur.pts;
|
||||
pts.push([ms, bytes]);
|
||||
while (pts.length > 2 && ms - pts[0][0] > 1000) pts.shift();
|
||||
if (pts.length < 2 || ms <= pts[0][0]) return;
|
||||
var bps = (bytes - pts[0][1]) * 8000 / (ms - pts[0][0]);
|
||||
cur.hist.push(bps);
|
||||
liveShow(phase, bps, cur);
|
||||
}
|
||||
function liveShow(phase, bps, cur) {
|
||||
var relay = lv && lv.relay, p = rateParts(bps);
|
||||
$('live').className = 'live' + (phase === 'up' ? ' up' : '');
|
||||
$('lv-ico').textContent = phase === 'up' ? '↑' : phase === 'down' ? '↓' : '';
|
||||
$('lv-phase').textContent = phase === 'ping' ? 'Задержка…'
|
||||
: phase === 'up' ? (relay ? 'клиент → браузер' : 'клиент → хост')
|
||||
: (relay ? 'браузер → клиент' : 'хост → клиент');
|
||||
$('lv-num').textContent = phase === 'ping' ? '…' : p[0];
|
||||
$('lv-unit').textContent = phase === 'ping' ? '' : p[1];
|
||||
// A log scale up to 1 Гбит/с, so a slow link moves the needle too.
|
||||
var f = Math.min(1, Math.log(1 + bps / 1e6) / Math.log(1001));
|
||||
$('lv-arc').style.strokeDashoffset = ARC * (1 - f);
|
||||
var h = cur ? cur.hist : [], max = 1.25 * Math.max.apply(null, h.concat(1)), line = '';
|
||||
for (var i = 0; i < h.length; i++) {
|
||||
line += (h.length > 1 ? 100 * i / (h.length - 1) : 0).toFixed(1) + ',' + (98 - 94 * h[i] / max).toFixed(1) + ' ';
|
||||
}
|
||||
$('lv-line').setAttribute('points', line);
|
||||
$('lv-area').setAttribute('points', h.length > 1 ? '0,100 ' + line + '100,100' : '');
|
||||
}
|
||||
|
||||
btn.onclick = function () {
|
||||
var relay = $('speed-relay').checked, st = {n: 0, start: 0, stop: function () {}};
|
||||
btn.disabled = true;
|
||||
note.textContent = 'Идёт замер…';
|
||||
$('speed').hidden = true;
|
||||
liveStart(relay, st);
|
||||
(relay ? relayTest(st) : post('/speed'))
|
||||
.then(function (res) {
|
||||
if (!res.ok) { note.textContent = res.error || 'ошибка'; return; }
|
||||
var d = res.data;
|
||||
$('sp-ping').textContent = d.ping_ms ? d.ping_ms.toFixed(d.ping_ms < 10 ? 1 : 0) + ' мс' : '-';
|
||||
$('sp-down').textContent = rate(d.down_bps);
|
||||
$('sp-up').textContent = d.up_bps ? rate(d.up_bps) : '-';
|
||||
$('sp-down-lbl').textContent = relay ? 'браузер → клиент' : 'хост → клиент';
|
||||
$('sp-up-lbl').textContent = relay ? 'клиент → браузер' : 'клиент → хост';
|
||||
$('speed').hidden = false;
|
||||
note.textContent = d.partial ? 'Замер остановлен по времени - показано то, что успело набраться' : '';
|
||||
})
|
||||
.catch(function (e) { note.textContent = 'Не удалось: ' + e.message; })
|
||||
.then(function () { liveStop(); btn.disabled = false; });
|
||||
};
|
||||
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,118 @@
|
||||
// The PIN lock: in a new browser tab the panel asks for the admin's PIN
|
||||
// before showing anything. The tab remembers it was unlocked (sessionStorage
|
||||
// lives as long as the tab, through reloads, and a new tab starts empty), so
|
||||
// a reload does not ask again. Loaded in <head>, before the page shows.
|
||||
// A device can be set to trust the PIN for a while: for that many minutes
|
||||
// after it was entered, new tabs there open without asking (localStorage,
|
||||
// this browser only).
|
||||
(function () {
|
||||
'use strict';
|
||||
var KEY = 'lpra.pin.ok';
|
||||
var ok = false;
|
||||
try { ok = sessionStorage.getItem(KEY) === '1'; } catch (e) { /* no storage: ask */ }
|
||||
if (ok) return;
|
||||
try {
|
||||
if (Date.now() < Number(localStorage.getItem('lpra.pin.until') || 0)) {
|
||||
sessionStorage.setItem(KEY, '1');
|
||||
return;
|
||||
}
|
||||
} catch (e) { /* ask */ }
|
||||
var root = document.documentElement;
|
||||
root.classList.add('pin-wait');
|
||||
function release() { root.classList.remove('pin-wait'); }
|
||||
function remember() { try { sessionStorage.setItem(KEY, '1'); } catch (e) { /* asked again next load */ } }
|
||||
// After the PIN itself: no asking on this device for the minutes it trusts.
|
||||
function trust() {
|
||||
try {
|
||||
var min = Number(localStorage.getItem('lpra.pin.grace') || 0);
|
||||
if (min > 0) localStorage.setItem('lpra.pin.until', String(Date.now() + min * 60000));
|
||||
} catch (e) { /* asks every tab */ }
|
||||
}
|
||||
|
||||
fetch('/api/panel/pin', {cache: 'no-store'})
|
||||
.then(function (r) { return r.ok ? r.json() : {pin: false}; })
|
||||
.then(function (d) {
|
||||
if (!d.pin) { remember(); release(); return; }
|
||||
if (document.body) lock(); else document.addEventListener('DOMContentLoaded', lock);
|
||||
})
|
||||
.catch(release);
|
||||
|
||||
function lock() {
|
||||
var LOCK = '<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="11" width="16" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg>';
|
||||
var BACK = '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 5H9l-6 7 6 7h12a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1z"/><path d="m16 9-5 6M11 9l5 6"/></svg>';
|
||||
var box = document.createElement('div');
|
||||
box.className = 'pinlock';
|
||||
box.setAttribute('role', 'dialog');
|
||||
box.setAttribute('aria-modal', 'true');
|
||||
box.setAttribute('aria-label', 'Ввод PIN-кода');
|
||||
var keys = '';
|
||||
['1', '2', '3', '4', '5', '6', '7', '8', '9', 'out', '0', 'back'].forEach(function (k) {
|
||||
var label = k === 'back' ? ' aria-label="Стереть">' + BACK : k === 'out' ? ' title="Выйти из панели">Выйти' : '>' + k;
|
||||
keys += '<button type="button" data-k="' + k + '"' + label + '</button>';
|
||||
});
|
||||
box.innerHTML =
|
||||
'<div class="pin-card">' +
|
||||
'<div class="pin-ico">' + LOCK + '</div>' +
|
||||
'<div class="pin-title">Введите PIN-код</div>' +
|
||||
'<div class="pin-dots"><i></i><i></i><i></i><i></i></div>' +
|
||||
'<div class="pin-msg" aria-live="polite"></div>' +
|
||||
'<div class="pin-pad">' + keys + '</div>' +
|
||||
'<form method="post" action="/logout" class="pin-out" hidden></form>' +
|
||||
'</div>';
|
||||
document.body.appendChild(box);
|
||||
requestAnimationFrame(function () { box.classList.add('on'); });
|
||||
var dots = box.querySelectorAll('.pin-dots i'), msg = box.querySelector('.pin-msg'), card = box.querySelector('.pin-card');
|
||||
var code = '', busy = false;
|
||||
function show() {
|
||||
for (var i = 0; i < 4; i++) dots[i].classList.toggle('on', i < code.length);
|
||||
}
|
||||
function press(k) {
|
||||
if (k === 'out') { box.querySelector('.pin-out').submit(); return; }
|
||||
if (busy) return;
|
||||
if (k === 'back') { code = code.slice(0, -1); show(); return; }
|
||||
if (code.length >= 4) return;
|
||||
code += k;
|
||||
msg.textContent = '';
|
||||
card.classList.remove('bad');
|
||||
show();
|
||||
if (code.length === 4) check();
|
||||
}
|
||||
function check() {
|
||||
busy = true;
|
||||
fetch('/api/panel/pin/check', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({pin: code})})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (r) {
|
||||
if (r.ok) {
|
||||
remember();
|
||||
trust();
|
||||
box.classList.add('done');
|
||||
release();
|
||||
setTimeout(function () { box.remove(); }, 320);
|
||||
document.removeEventListener('keydown', onKey, true);
|
||||
return;
|
||||
}
|
||||
if (r.out) { location.href = '/login'; return; }
|
||||
card.classList.remove('bad');
|
||||
void card.offsetWidth; // restart the shake
|
||||
card.classList.add('bad');
|
||||
msg.textContent = r.error + (r.left ? ' - осталось попыток: ' + r.left : '');
|
||||
setTimeout(function () { code = ''; show(); busy = false; }, 380);
|
||||
})
|
||||
.catch(function () { msg.textContent = 'нет связи с сервером'; code = ''; show(); busy = false; });
|
||||
}
|
||||
box.querySelector('.pin-pad').addEventListener('click', function (ev) {
|
||||
var b = ev.target.closest('button');
|
||||
if (b) press(b.dataset.k);
|
||||
});
|
||||
// Typed digits, ahead of anything on the page (the terminal included).
|
||||
function onKey(ev) {
|
||||
if (/^[0-9]$/.test(ev.key)) press(ev.key);
|
||||
else if (ev.key === 'Backspace') press('back');
|
||||
else if (ev.key !== 'Tab') return;
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
}
|
||||
document.addEventListener('keydown', onKey, true);
|
||||
if (document.activeElement && document.activeElement.blur) document.activeElement.blur();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,27 @@
|
||||
// The look of the panel, as each user set it (Аккаунт › Вид): applied at
|
||||
// once on load from what the page came with, and again on every change.
|
||||
(function () {
|
||||
'use strict';
|
||||
let p = Object.assign({}, (window.APP && window.APP.prefs) || {});
|
||||
function apply() {
|
||||
const b = document.body;
|
||||
b.dataset.tabicons = p.tabIcons || 'left';
|
||||
b.classList.toggle('compact', !!p.compact);
|
||||
b.classList.toggle('no-ftime', p.fileTime === 'hide');
|
||||
b.classList.toggle('no-motion', p.motion === 'off');
|
||||
// Open terminals take the new size at once.
|
||||
if (window.Workspace) Workspace.panes().forEach(t => {
|
||||
if (t.term && t.term.options.fontSize !== termFont()) { t.term.options.fontSize = termFont(); t.fit(); }
|
||||
});
|
||||
}
|
||||
const termFont = () => p.termFont || (matchMedia('(pointer: coarse)').matches ? 13 : 14);
|
||||
async function set(k, v) {
|
||||
p[k] = v;
|
||||
apply();
|
||||
const r = await fetch('/api/panel/account/prefs', {method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
credentials: 'same-origin', body: JSON.stringify(p)}).then(x => x.json()).catch(() => ({ok: false}));
|
||||
return r.ok;
|
||||
}
|
||||
apply();
|
||||
window.Prefs = {get: () => Object.assign({}, p), set, apply, termFont};
|
||||
})();
|
||||
@@ -0,0 +1,54 @@
|
||||
// Mac-like scrollbars: a scrolled element shows its thin scrollbar, and so
|
||||
// does one whose edge the pointer is at; after a second at rest it fades out.
|
||||
// Elements with nothing to scroll never show one. The look is in app.css.
|
||||
(function () {
|
||||
'use strict';
|
||||
var live = new Map(); // element -> {v: shown 0..1, until: ms to stay}
|
||||
var raf = 0;
|
||||
function el(t) { return t === document || t === window ? document.documentElement : t; }
|
||||
function scrollable(e) {
|
||||
return e.scrollHeight > e.clientHeight + 1 || e.scrollWidth > e.clientWidth + 1;
|
||||
}
|
||||
function show(e, ms) {
|
||||
if (!e || !e.style || !scrollable(e)) return;
|
||||
var s = live.get(e) || {v: 0, until: 0};
|
||||
s.until = Math.max(s.until, performance.now() + ms);
|
||||
live.set(e, s);
|
||||
if (!raf) raf = setTimeout(tick, 16);
|
||||
}
|
||||
var last = 0;
|
||||
// A timer, not animation frames: those stop in a tab out of sight, and the
|
||||
// bar would stay shown.
|
||||
function tick() {
|
||||
var now = performance.now();
|
||||
var dt = Math.min(64, now - (last || now - 16));
|
||||
last = now;
|
||||
live.forEach(function (s, e) {
|
||||
// In fast (about 0.12 s), out slowly (about 0.4 s).
|
||||
var want = now < s.until ? 1 : 0;
|
||||
s.v = want ? Math.min(1, s.v + dt / 120) : Math.max(0, s.v - dt / 400);
|
||||
e.style.setProperty('--sb', s.v.toFixed(3));
|
||||
if (!want && s.v === 0) { e.style.removeProperty('--sb'); live.delete(e); }
|
||||
});
|
||||
raf = live.size ? setTimeout(tick, 16) : 0;
|
||||
if (!raf) last = 0;
|
||||
}
|
||||
document.addEventListener('scroll', function (ev) { show(el(ev.target), 900); }, {capture: true, passive: true});
|
||||
// The pointer at a scrollable element's right or bottom edge shows its bar.
|
||||
var moved = null;
|
||||
document.addEventListener('pointermove', function (ev) {
|
||||
if (ev.pointerType !== 'mouse') return;
|
||||
if (!moved) requestAnimationFrame(function () {
|
||||
var m = moved; moved = null;
|
||||
for (var e = m.target; e && e.nodeType === 1; e = e.parentElement) {
|
||||
if (!scrollable(e)) continue;
|
||||
var st = getComputedStyle(e);
|
||||
if (!/(auto|scroll)/.test(st.overflowY + st.overflowX) && e !== document.documentElement) continue;
|
||||
var r = e === document.documentElement ? {right: innerWidth, bottom: innerHeight} : e.getBoundingClientRect();
|
||||
if (m.x >= r.right - 14 || m.y >= r.bottom - 14) show(e, 700);
|
||||
break;
|
||||
}
|
||||
});
|
||||
moved = {target: ev.target, x: ev.clientX, y: ev.clientY};
|
||||
}, {passive: true});
|
||||
})();
|
||||
@@ -0,0 +1,471 @@
|
||||
// Terminal panes of the workspace: a shell on a machine, or a shell the
|
||||
// machine opens by SSH. Also the touch helpers for typing in them (extra
|
||||
// keys, long-press copy). The workspace (workspace.js) holds the tabs.
|
||||
// Touch helpers, for the terminal being typed in: the extra keys with their
|
||||
// CTRL / ALT, and selecting and copying with a long press.
|
||||
window.TermKeys = (function () {
|
||||
const touch = window.matchMedia('(pointer: coarse)').matches;
|
||||
const cur = () => window.TermPane && window.TermPane.active();
|
||||
// CTRL and ALT: a tap arms the key for the next key press, a long press
|
||||
// keeps it on until tapped again.
|
||||
const mods = { ctrl: 0, alt: 0 }; // 0 off, 1 next key, 2 locked
|
||||
const modBtn = {};
|
||||
document.querySelectorAll('#keys [data-mod]').forEach(function (b) { modBtn[b.dataset.mod] = b; });
|
||||
function showMods() {
|
||||
for (const m in mods) {
|
||||
modBtn[m].classList.toggle('on', mods[m] === 1);
|
||||
modBtn[m].classList.toggle('lock', mods[m] === 2);
|
||||
}
|
||||
}
|
||||
function useMods() {
|
||||
const used = { ctrl: !!mods.ctrl, alt: !!mods.alt };
|
||||
for (const m in mods) if (mods[m] === 1) mods[m] = 0;
|
||||
showMods();
|
||||
return used;
|
||||
}
|
||||
function ctrlChar(c) {
|
||||
if (c === ' ' || c === '@') return '\x00';
|
||||
if (c === '?') return '\x7f';
|
||||
const code = c.toUpperCase().charCodeAt(0);
|
||||
return code >= 64 && code <= 95 ? String.fromCharCode(code & 31) : c;
|
||||
}
|
||||
function withMods(data) {
|
||||
if (!mods.ctrl && !mods.alt) return data;
|
||||
const m = useMods();
|
||||
if (m.ctrl && data.length === 1) data = ctrlChar(data);
|
||||
return m.alt ? '\x1b' + data : data;
|
||||
}
|
||||
// xterm's modifier parameter: 1 + shift(1) + alt(2) + ctrl(4).
|
||||
const CSI = { up: 'A', down: 'B', right: 'C', left: 'D', home: 'H', end: 'F' };
|
||||
const TILDE = { pgup: 5, pgdn: 6 };
|
||||
function keySeq(k, term) {
|
||||
const m = useMods(), p = 1 + (m.alt ? 2 : 0) + (m.ctrl ? 4 : 0);
|
||||
if (CSI[k]) {
|
||||
if (p > 1) return '\x1b[1;' + p + CSI[k];
|
||||
return (term.modes.applicationCursorKeysMode ? '\x1bO' : '\x1b[') + CSI[k];
|
||||
}
|
||||
if (TILDE[k]) return '\x1b[' + TILDE[k] + (p > 1 ? ';' + p : '') + '~';
|
||||
let s = k === 'esc' ? '\x1b' : k === 'tab' ? '\t' : k;
|
||||
if (m.ctrl && s.length === 1) s = ctrlChar(s);
|
||||
return m.alt ? '\x1b' + s : s;
|
||||
}
|
||||
let press = null;
|
||||
document.querySelectorAll('#keys button').forEach(function (b) {
|
||||
// Keep the focus (and the on-screen keyboard) on the terminal.
|
||||
b.addEventListener('pointerdown', function (e) {
|
||||
e.preventDefault();
|
||||
const mod = b.dataset.mod;
|
||||
if (mod) press = { mod: mod, timer: setTimeout(function () { mods[mod] = 2; press = null; showMods(); }, 450) };
|
||||
});
|
||||
b.addEventListener('pointerup', function (e) {
|
||||
e.preventDefault();
|
||||
const mod = b.dataset.mod;
|
||||
if (mod) {
|
||||
if (press && press.mod === mod) {
|
||||
clearTimeout(press.timer);
|
||||
press = null;
|
||||
mods[mod] = mods[mod] ? 0 : 1;
|
||||
showMods();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const s = cur();
|
||||
if (s) s.send(keySeq(b.dataset.k, s.term));
|
||||
});
|
||||
b.addEventListener('pointercancel', function () { if (press) { clearTimeout(press.timer); press = null; } });
|
||||
b.addEventListener('contextmenu', function (e) { e.preventDefault(); });
|
||||
});
|
||||
|
||||
// --- Selecting and copying on touch screens ---
|
||||
// xterm.js has no touch selection of its own. A long press selects the word
|
||||
// under the finger and dragging on from there stretches the selection; a
|
||||
// menu then copies it or pastes. A plain tap or swipe still types and scrolls.
|
||||
const menu = document.getElementById('selmenu');
|
||||
let selecting = false;
|
||||
function showMenu() {
|
||||
const s = cur();
|
||||
const keys = document.getElementById('keys').getBoundingClientRect();
|
||||
menu.style.top = '';
|
||||
menu.style.bottom = (window.innerHeight - keys.top + 14) + 'px';
|
||||
menu.querySelector('[data-a="copy"]').disabled = !(s && s.term.hasSelection());
|
||||
menu.hidden = false;
|
||||
}
|
||||
function selectionChanged(s) {
|
||||
if (!touch || s !== cur()) return;
|
||||
if (s.term.hasSelection()) showMenu();
|
||||
else if (!selecting) menu.hidden = true;
|
||||
}
|
||||
if (touch) {
|
||||
const box = document.getElementById('area');
|
||||
let timer = null, start = null, anchor = null;
|
||||
function cellAt(term, el, x, y) {
|
||||
const r = el.querySelector('.xterm-screen').getBoundingClientRect();
|
||||
const col = Math.max(0, Math.min(term.cols - 1, Math.floor((x - r.left) / (r.width / term.cols))));
|
||||
const row = Math.max(0, Math.min(term.rows - 1, Math.floor((y - r.top) / (r.height / term.rows))));
|
||||
return { col: col, row: row + term.buffer.active.viewportY };
|
||||
}
|
||||
function selectWord(term, c) {
|
||||
const line = term.buffer.active.getLine(c.row);
|
||||
const text = line ? line.translateToString(false) : '';
|
||||
const word = ch => ch && !/\s/.test(ch);
|
||||
let a = c.col, b = c.col;
|
||||
if (word(text[c.col])) {
|
||||
while (a > 0 && word(text[a - 1])) a--;
|
||||
while (b < text.length - 1 && word(text[b + 1])) b++;
|
||||
}
|
||||
term.select(a, c.row, b - a + 1);
|
||||
anchor = { from: c.row * term.cols + a, to: c.row * term.cols + b };
|
||||
}
|
||||
function stretch(term, c) {
|
||||
const p = c.row * term.cols + c.col;
|
||||
const from = Math.min(anchor.from, p), to = Math.max(anchor.to, p);
|
||||
term.select(from % term.cols, Math.floor(from / term.cols), to - from + 1);
|
||||
}
|
||||
function hideMenu() { menu.hidden = true; const s = cur(); if (s) s.term.clearSelection(); anchor = null; }
|
||||
box.addEventListener('touchstart', function (e) {
|
||||
if (e.touches.length !== 1 || !e.target.closest('.pane-body')) { clearTimeout(timer); return; }
|
||||
const t = e.touches[0];
|
||||
start = { x: t.clientX, y: t.clientY };
|
||||
if (!menu.hidden) hideMenu();
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(function () {
|
||||
const s = cur();
|
||||
if (!s) return;
|
||||
selecting = true;
|
||||
selectWord(s.term, cellAt(s.term, s.el, start.x, start.y));
|
||||
showMenu();
|
||||
if (navigator.vibrate) navigator.vibrate(15);
|
||||
}, 450);
|
||||
}, { passive: true, capture: true });
|
||||
box.addEventListener('touchmove', function (e) {
|
||||
const t = e.touches[0], s = cur();
|
||||
if (selecting && s) {
|
||||
e.preventDefault(); // no scrolling while the selection stretches
|
||||
e.stopPropagation();
|
||||
stretch(s.term, cellAt(s.term, s.el, t.clientX, t.clientY));
|
||||
} else if (start && Math.hypot(t.clientX - start.x, t.clientY - start.y) > 8) {
|
||||
clearTimeout(timer); // a swipe: let it scroll
|
||||
}
|
||||
}, { passive: false, capture: true });
|
||||
box.addEventListener('touchend', function (e) {
|
||||
clearTimeout(timer);
|
||||
if (!selecting) return;
|
||||
selecting = false;
|
||||
e.preventDefault(); // no tap: the keyboard stays as it was
|
||||
e.stopPropagation();
|
||||
showMenu();
|
||||
}, { passive: false, capture: true });
|
||||
box.addEventListener('contextmenu', function (e) { e.preventDefault(); });
|
||||
|
||||
const statusEl = document.getElementById('status');
|
||||
function flash(t, cls) {
|
||||
statusEl.textContent = t; statusEl.className = cls;
|
||||
setTimeout(function () { const s = cur(); if (s) s.setState(s.state, s.text); }, 1500);
|
||||
}
|
||||
function copyText(text) {
|
||||
if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(text);
|
||||
// Plain http: the old way, through a field that is there for a moment.
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
const ok = document.execCommand('copy');
|
||||
ta.remove();
|
||||
return ok ? Promise.resolve() : Promise.reject(new Error('нет доступа к буферу'));
|
||||
}
|
||||
menu.addEventListener('pointerdown', function (e) { e.preventDefault(); });
|
||||
menu.addEventListener('click', function (e) {
|
||||
const b = e.target.closest('button'), s = cur();
|
||||
if (!b || !s) return;
|
||||
const a = b.dataset.a;
|
||||
if (a === 'copy') {
|
||||
const text = s.term.getSelection();
|
||||
hideMenu();
|
||||
copyText(text).then(function () { flash('скопировано', 'st-ok'); }, function (err) { flash('не скопировалось: ' + err.message, 'st-err'); });
|
||||
} else if (a === 'paste') {
|
||||
hideMenu();
|
||||
if (!navigator.clipboard || !navigator.clipboard.readText) { flash('вставка: браузер не даёт буфер', 'st-err'); return; }
|
||||
navigator.clipboard.readText().then(function (t) { if (t) s.term.paste(t); }, function () { flash('вставка: нет доступа к буферу', 'st-err'); });
|
||||
} else if (a === 'all') {
|
||||
s.term.selectAll();
|
||||
showMenu();
|
||||
} else {
|
||||
hideMenu();
|
||||
}
|
||||
});
|
||||
}
|
||||
return { withMods: withMods, selectionChanged: selectionChanged };
|
||||
})();
|
||||
(function () {
|
||||
'use strict';
|
||||
const W = window.Workspace;
|
||||
const touch = W.touch;
|
||||
const statusEl = document.getElementById('status');
|
||||
const base = '/api/panel/term/';
|
||||
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(W.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.
|
||||
// dir, for a shell on the machine, is the folder it starts in.
|
||||
constructor(agent, ssh, dir) {
|
||||
this.agent = agent;
|
||||
this.ssh = ssh || null;
|
||||
this.dir = dir || '';
|
||||
this.kind = ssh ? 'ssh' : 'term';
|
||||
// Where its files are, for dropped paths: the machine, or the SSH host
|
||||
// through it.
|
||||
this.source = ssh
|
||||
? {kind: 'ssh', id: agent.id + '|' + ssh.user + '@' + ssh.host + ':' + (ssh.port || 22), label: ssh.user + '@' + ssh.host}
|
||||
: {kind: 'agent', id: agent.id, label: agent.name};
|
||||
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: window.Prefs ? Prefs.termFont() : (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', () => W.setActivePane(this), true);
|
||||
this.term.textarea.addEventListener('focus', () => W.setActivePane(this));
|
||||
this.el.querySelector('.pane-x').onclick = ev => { ev.stopPropagation(); W.closePane(this); };
|
||||
if (W.desk) this.el.querySelector('.pane-head').addEventListener('pointerdown', ev => W.startDrag(ev, 'pane', this));
|
||||
this.el.querySelector('.pane-head').addEventListener('contextmenu', ev => { if (window.MachineDialog) MachineDialog.menu(ev, this.agent); });
|
||||
this.open();
|
||||
}
|
||||
setState(state, text) {
|
||||
this.state = state; this.text = text;
|
||||
this.el.querySelector('.dot').className = 'dot ' + state;
|
||||
const t = W.tabOf(this);
|
||||
if (t) W.renderTab(t);
|
||||
W.showStatus();
|
||||
}
|
||||
async open() {
|
||||
try {
|
||||
let opt = { method: 'POST' };
|
||||
if (this.dir) opt = { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ dir: this.dir }) };
|
||||
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 */ } }
|
||||
}
|
||||
title() { return this.ssh ? this.ssh.host : this.agent.name; }
|
||||
tooltip() { return who(this); }
|
||||
live() { return !!this.sid && !this.ended && !this.closed; }
|
||||
focus() { this.term.focus(); }
|
||||
dropTarget() { return {kind: 'term', source: this.source}; }
|
||||
// Files dropped here from the same place: their paths, quoted, typed in.
|
||||
drop(item) { this.send(WSUtil.shellQuoteAll(item.paths) + ' '); this.term.focus(); }
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
W.paneTypes.term = (agent, opts) => new Session(agent, null, opts.dir);
|
||||
W.paneTypes.ssh = (agent, opts) => new Session(agent, opts.ssh);
|
||||
// For the touch helpers: the terminal being typed in.
|
||||
window.TermPane = {active: () => { const p = W.activePane(); return p && p.term ? p : null; }};
|
||||
})();
|
||||
@@ -0,0 +1,84 @@
|
||||
// The panel's own question and input dialogs in place of the browser's
|
||||
// confirm() and prompt(): UI.confirm(text, {ok, danger}) and
|
||||
// UI.prompt(text, value) answer with promises. A form with data-confirm asks
|
||||
// first and is sent only on «да».
|
||||
(function () {
|
||||
'use strict';
|
||||
let dlg = null;
|
||||
function make() {
|
||||
dlg = document.createElement('dialog');
|
||||
dlg.className = 'uidlg';
|
||||
dlg.innerHTML = '<form method="dialog"><p class="ui-text"></p><input class="ui-input" autocomplete="off" spellcheck="false">' +
|
||||
'<div class="row end"><button type="button" class="btn ghost close ui-no">Отмена</button><button type="submit" class="ui-yes">OK</button></div></form>';
|
||||
document.body.appendChild(dlg);
|
||||
}
|
||||
function ask(text, o) {
|
||||
if (!dlg) make();
|
||||
if (dlg.open) dlg.close('');
|
||||
const input = dlg.querySelector('.ui-input'), yes = dlg.querySelector('.ui-yes');
|
||||
// Long words (a file name) stay on one line, cut with "…"; the window
|
||||
// is as wide as its words, up to the screen's room.
|
||||
const t = dlg.querySelector('.ui-text');
|
||||
t.textContent = '';
|
||||
// "{chip}" in the text: the chip stands there, inside the sentence.
|
||||
const parts = String(text).split('{chip}');
|
||||
text = parts.shift();
|
||||
String(text).split(/(\s+)/).forEach(w => {
|
||||
if (w.length > 28 && !/\s/.test(w)) {
|
||||
const s = document.createElement('span');
|
||||
s.className = 'ui-long'; s.textContent = w; s.title = w;
|
||||
t.appendChild(s);
|
||||
} else t.appendChild(document.createTextNode(w));
|
||||
});
|
||||
// Extras of a question about an action: where (a chip), the command in
|
||||
// mono, a note under it.
|
||||
if (parts.length && o.chip) {
|
||||
const c = document.createElement('span'); c.className = 'ui-chip'; c.textContent = o.chip; c.title = o.chip;
|
||||
t.append(c, parts.join(''));
|
||||
o = Object.assign({}, o, {chip: ''});
|
||||
}
|
||||
dlg.querySelectorAll('.ui-extra').forEach(e => e.remove());
|
||||
if (o.chip || o.code || o.note) {
|
||||
const box = document.createElement('div');
|
||||
box.className = 'ui-extra';
|
||||
if (o.chip) { const c = document.createElement('div'); c.className = 'ui-chip'; c.textContent = o.chip; c.title = o.chip; box.appendChild(c); }
|
||||
if (o.code) { const p = document.createElement('pre'); p.className = 'ui-code'; p.textContent = o.code; box.appendChild(p); }
|
||||
if (o.note) { const n = document.createElement('p'); n.className = 'ui-note'; n.textContent = o.note; box.appendChild(n); }
|
||||
t.after(box);
|
||||
}
|
||||
input.hidden = !o.input;
|
||||
input.value = o.value || '';
|
||||
yes.textContent = o.ok || 'OK';
|
||||
yes.className = 'ui-yes' + (o.danger ? ' btn danger' : '');
|
||||
// «Отмена» red, unless «да» is the red one (a delete): never two reds.
|
||||
dlg.querySelector('.ui-no').classList.toggle('close', !o.danger);
|
||||
return new Promise(done => {
|
||||
const end = v => { dlg.onclose = null; done(v); };
|
||||
dlg.querySelector('.ui-no').onclick = () => dlg.close('');
|
||||
dlg.querySelector('form').onsubmit = ev => { ev.preventDefault(); dlg.close('ok'); };
|
||||
dlg.onclose = () => end(dlg.returnValue === 'ok' ? (o.input ? input.value : true) : (o.input ? null : false));
|
||||
dlg.returnValue = '';
|
||||
dlg.showModal();
|
||||
if (o.input) { input.focus(); input.select(); } else yes.focus();
|
||||
});
|
||||
}
|
||||
const UI = {
|
||||
confirm: (text, o) => ask(text, Object.assign({ok: 'Да'}, o)),
|
||||
prompt: (text, value) => ask(text, {input: true, value, ok: 'OK'})
|
||||
};
|
||||
window.UI = UI;
|
||||
// Forms that ask first: data-confirm, and data-danger for a red «да».
|
||||
document.addEventListener('submit', ev => {
|
||||
const f = ev.target;
|
||||
if (!f.dataset || !f.dataset.confirm) return;
|
||||
if (f.dataset.confirmed) { delete f.dataset.confirmed; return; }
|
||||
ev.preventDefault();
|
||||
ev.stopImmediatePropagation();
|
||||
const by = ev.submitter;
|
||||
UI.confirm(f.dataset.confirm, {ok: f.dataset.ok || 'Да', danger: 'danger' in f.dataset}).then(ok => {
|
||||
if (!ok) return;
|
||||
f.dataset.confirmed = '1';
|
||||
f.requestSubmit(by && f.contains(by) ? by : undefined);
|
||||
});
|
||||
}, true);
|
||||
})();
|
||||
@@ -0,0 +1,106 @@
|
||||
// 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.
|
||||
// It is mounted on the settings fragment each time the shell puts it in place.
|
||||
window.UsersTable = {mount: function (root) {
|
||||
'use strict';
|
||||
var table = root.querySelector('table.users');
|
||||
if (!table) return;
|
||||
var $ = function (id) { return root.querySelector('#' + 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'; };
|
||||
}};
|
||||
@@ -0,0 +1,834 @@
|
||||
// The workspace of the Терминал view: panes - terminals, file managers - of
|
||||
// one or more machines, in tabs. It lives as long as the page, so moving to
|
||||
// Машины or Настройки and back keeps every session. With one tab there is
|
||||
// only 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
|
||||
// panes, split side by side or one above the other - and a pane dragged out
|
||||
// of it back to the strip. Splits resize by their gutters; a tab is renamed
|
||||
// by a double click on its title. Pane types register in Workspace.paneTypes.
|
||||
(function () {
|
||||
'use strict';
|
||||
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>';
|
||||
|
||||
// ---------- tabs ----------
|
||||
const tabs = [];
|
||||
let active = null, activePane = null;
|
||||
const tabOf = s => tabs.find(t => t.panes.includes(s));
|
||||
const KIND_ICON = {
|
||||
term: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="m5 7 5 5-5 5M12 17h7"/></svg>',
|
||||
files: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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>'
|
||||
};
|
||||
|
||||
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-kinds"></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); });
|
||||
// Its machine's Порты, Статус, Изменить: a right click on the title.
|
||||
t.el.addEventListener('contextmenu', ev => {
|
||||
const p = t.panes.includes(activePane) ? activePane : t.panes[0];
|
||||
if (window.MachineDialog && !ev.target.closest('input')) MachineDialog.menu(ev, p.agent);
|
||||
});
|
||||
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 name = t.panes[0].title();
|
||||
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) +
|
||||
18 * new Set(t.panes.map(p => /files$/.test(p.kind))).size)));
|
||||
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(); placeFloat(); });
|
||||
|
||||
function renderTab(t) {
|
||||
t.el.querySelector('.tab-title').textContent = tabTitle(t);
|
||||
// Before the name, what the tab holds: a terminal, files, or both.
|
||||
const kinds = [...new Set(t.panes.map(p => /files$/.test(p.kind) ? 'files' : 'term'))];
|
||||
const k = t.el.querySelector('.tab-kinds');
|
||||
if (k.dataset.k !== kinds.join()) {
|
||||
k.dataset.k = kinds.join();
|
||||
k.innerHTML = kinds.map(x => KIND_ICON[x]).join('');
|
||||
k.title = kinds.map(x => x === 'files' ? 'файлы' : 'терминал').join(' и ');
|
||||
}
|
||||
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(p => p.tooltip ? p.tooltip() : p.title()).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.focus();
|
||||
updateMode();
|
||||
// The tab comes in moving a little; the "+" goes by where it ends up.
|
||||
setTimeout(placeFloat, 260);
|
||||
}
|
||||
function setActivePane(s) {
|
||||
activePane = s;
|
||||
// The phone's extra keys are a terminal's: a file pane goes without.
|
||||
const term = s.kind === 'term' || s.kind === 'ssh';
|
||||
if (touch && document.body.classList.contains('term-active') !== term) {
|
||||
document.body.classList.toggle('term-active', term);
|
||||
setTimeout(() => active && layout(active, false), 0);
|
||||
}
|
||||
document.querySelectorAll('.pane.active').forEach(p => p.classList.toggle('active', p === s.el));
|
||||
s.el.classList.add('active');
|
||||
showStatus();
|
||||
}
|
||||
function showStatus() {
|
||||
// The header's status line is the workspace's only while it is shown.
|
||||
if (!activePane || document.querySelector('main#app').dataset.view !== 'terminal') { 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);
|
||||
placeFloat();
|
||||
}
|
||||
// With one tab and a file pane in the top right corner, the "+" joins
|
||||
// that pane's first toolbar after "Обновить". It belongs to the scrolling
|
||||
// file pane, so it stays beside the toolbar when the pane is scrolled.
|
||||
// Otherwise it returns to the corner of .area.
|
||||
function placeFloat() {
|
||||
const p = tabs.length === 1 ? tabs[0].panes.find(s => s.el.classList.contains('under-plus')) : null;
|
||||
const bars = p && /files$/.test(p.kind) ? p.el.querySelectorAll('.fm-bar') : [];
|
||||
// A bar folded in two (a phone or a narrow pane) keeps the corner "+".
|
||||
const on = bars.length > 1 && bars[0].offsetParent !== null && bars[0].offsetHeight < 48;
|
||||
floatAdd.classList.toggle('in-files', on);
|
||||
if (on) {
|
||||
bars[0].appendChild(floatAdd);
|
||||
if (bars[0].offsetHeight >= 48) {
|
||||
floatAdd.classList.remove('in-files');
|
||||
area.insertBefore(floatAdd, area.firstChild);
|
||||
}
|
||||
} else {
|
||||
area.insertBefore(floatAdd, area.firstChild);
|
||||
}
|
||||
floatAdd.style.top = floatAdd.style.right = '';
|
||||
}
|
||||
// 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 openPane(kind, agent, opts) {
|
||||
const make = paneTypes[kind];
|
||||
if (!make) return null;
|
||||
$('empty').hidden = true;
|
||||
const t = makeTab([make(agent, opts || {})]);
|
||||
activate(t);
|
||||
return t;
|
||||
}
|
||||
function openTerminal(agent, ssh) { return openPane(ssh ? 'ssh' : 'term', agent, {ssh}); }
|
||||
async function closeTab(t) {
|
||||
const i = tabs.indexOf(t);
|
||||
if (i < 0) return;
|
||||
if (t.panes.length > 1 && !await UI.confirm('Закрыть вкладку с ' + t.panes.length + ' панелями?', {ok: 'Закрыть', danger: true})) 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; document.body.classList.remove('term-active'); }
|
||||
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);
|
||||
// The pane in the top right corner sits under the corner "+": it keeps
|
||||
// its first bar clear of it.
|
||||
let corner = 0;
|
||||
L.panes.forEach((p, i) => { const c = L.panes[corner]; if (p.y < c.y || (p.y === c.y && p.x + p.w > c.x + c.w)) corner = i; });
|
||||
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';
|
||||
s.el.classList.toggle('under-plus', i === corner);
|
||||
});
|
||||
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()); placeFloat(); }, 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;
|
||||
// Safari may start a native drag for the tab or header text while our
|
||||
// pointer-based drag is active, producing a second image under the cursor.
|
||||
// External files are unaffected: their dragstart originates outside this page.
|
||||
document.addEventListener('dragstart', ev => {
|
||||
if (!drag && !document.body.classList.contains('ai-dragging')) return;
|
||||
ev.preventDefault();
|
||||
ev.stopImmediatePropagation();
|
||||
}, true);
|
||||
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;
|
||||
// The tab open when the drag began: it comes back if nothing is done.
|
||||
drag.origActive = active;
|
||||
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.tooltip ? drag.obj.tooltip() : drag.obj.title());
|
||||
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; })();
|
||||
// A tab held over another tab's title for a moment opens that one, so
|
||||
// the dragged tab can be dropped into it.
|
||||
if (drag.kind === 'tab') {
|
||||
const over = inStrip ? tabs.find(t => t !== drag.obj && t !== active && (() => { const r = t.el.getBoundingClientRect(); return ev.clientX >= r.left && ev.clientX <= r.right; })()) : null;
|
||||
if (over !== drag.overTab) {
|
||||
clearTimeout(drag.overTimer);
|
||||
drag.overTab = over;
|
||||
if (over) drag.overTimer = setTimeout(() => { if (drag && drag.overTab === over) activate(over); }, 450);
|
||||
}
|
||||
}
|
||||
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;
|
||||
clearTimeout(d.overTimer);
|
||||
// Dropped with nothing done (or only moved along the strip): back to the
|
||||
// tab that was dragged, if another one was opened meanwhile.
|
||||
const tg0 = d.target;
|
||||
if (d.kind === 'tab' && d.origActive && active !== d.origActive && !(tg0 && tg0.kind === 'merge')) {
|
||||
const back = d.origActive;
|
||||
setTimeout(() => activate(back), 0);
|
||||
}
|
||||
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.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;
|
||||
addMenu.innerHTML = '';
|
||||
const esc = v => String(v).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||
const item = (parent, html, fn, cls) => {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button'; b.className = 'item ' + (cls || ''); b.innerHTML = html;
|
||||
b.onclick = fn;
|
||||
parent.appendChild(b);
|
||||
return b;
|
||||
};
|
||||
const FILES = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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>';
|
||||
// Both groups keep their contents mounted so they can animate in both directions.
|
||||
const group = (icon, label, kind, mode) => {
|
||||
let loaded = false;
|
||||
const g = item(addMenu, '<span class="ico">' + icon + '</span><span><b>' + label + '</b></span><span class="chev" aria-hidden="true">›</span>', () => {
|
||||
const opening = g.getAttribute('aria-expanded') !== 'true';
|
||||
addMenu.querySelectorAll('.item[aria-expanded="true"]').forEach(b => {
|
||||
b.setAttribute('aria-expanded', 'false');
|
||||
b.nextSibling.classList.remove('open');
|
||||
b.nextSibling.inert = true;
|
||||
});
|
||||
if (!opening) { placeMenu(anchor); return; }
|
||||
g.setAttribute('aria-expanded', 'true');
|
||||
list.classList.add('open');
|
||||
list.inert = false;
|
||||
placeMenu(anchor);
|
||||
if (loaded) return;
|
||||
loaded = true;
|
||||
(agentsCache ? Promise.resolve(agentsCache) : fetch('/api/panel/agents/presence', { cache: 'no-store' }).then(r => r.json()))
|
||||
.then(all => {
|
||||
agentsCache = all;
|
||||
box.innerHTML = '';
|
||||
all.filter(a => !here || a.id !== here.id).forEach(a => {
|
||||
const b = item(box, '<span class="ico"><span class="dot ' + (a.online ? 'ok' : 'off') + '"></span></span><span><b>' + esc(a.name) + '</b><small>' +
|
||||
esc(a.user + '@' + a.hostname) + (a.online ? '' : ' · ' + esc(a.seen)) + '</small></span>',
|
||||
() => { closeMenu(); openPane(kind, { id: a.id, name: a.name, host: a.hostname, user: a.user }); }, 'machine');
|
||||
b.disabled = !a.online;
|
||||
});
|
||||
if (list.isConnected) placeMenu(anchor);
|
||||
})
|
||||
.catch(() => { box.innerHTML = '<div class="muted" style="padding:8px 10px">не загрузилось</div>'; });
|
||||
});
|
||||
g.setAttribute('aria-expanded', 'false');
|
||||
const list = document.createElement('div');
|
||||
list.className = 'mlist';
|
||||
list.inert = true;
|
||||
const inner = document.createElement('div');
|
||||
inner.className = 'mlist-inner';
|
||||
const content = document.createElement('div');
|
||||
content.className = 'mlist-content';
|
||||
inner.appendChild(content);
|
||||
list.appendChild(inner);
|
||||
g.after(list);
|
||||
if (here) item(content, '<span class="ico">+</span><span><b>Здесь</b><small>' + esc(here.name) + ' · ' + esc(here.user + '@' + here.host) + '</small></span>',
|
||||
() => {
|
||||
closeMenu();
|
||||
// A terminal from a machine's file pane starts in its folder, a
|
||||
// file pane from a machine's terminal in the terminal's (asked of
|
||||
// the machine; home if it cannot tell) - unless turned off.
|
||||
const on = !(window.Prefs && Prefs.get().openHere === 'off');
|
||||
let opts = null;
|
||||
if (on && kind === 'term' && activePane.kind === 'files') opts = {dir: activePane.cwd};
|
||||
else if (on && kind === 'files' && activePane.kind === 'term' && activePane.sid) {
|
||||
opts = {dir: new MachineSource(here).op('termcwd', {session: activePane.sid}).then(r => r.ok && r.data ? r.data.path : '')};
|
||||
}
|
||||
openPane(kind, here, opts);
|
||||
});
|
||||
const box = document.createElement('div');
|
||||
box.innerHTML = '<div class="muted" style="padding:8px 10px;font-size:13px">загрузка…</div>';
|
||||
content.appendChild(box);
|
||||
item(content, '<span class="ico">' + KEYI + '</span><span><b>SSH-хост…</b><small>машина откроет SSH к другому хосту</small></span>',
|
||||
() => { closeMenu(); openSSH(here, mode); });
|
||||
if (!paneTypes[kind]) g.disabled = true;
|
||||
return g;
|
||||
};
|
||||
group('>_', 'Терминал', 'term', 'term');
|
||||
group(FILES, 'Файловый менеджер', 'files', 'files');
|
||||
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 sshInto = null, sshDlg = null, sshMode = 'term';
|
||||
// openSSH asks where to connect; mode says what opens there: a terminal
|
||||
// ('term') or a file manager ('files', through Workspace.sshFiles).
|
||||
// into: a file pane whose SSH connection is lost - the dialog comes with
|
||||
// its host, port and user, and connects that pane again.
|
||||
function openSSH(here, mode, into) {
|
||||
if (!sshDlg) sshDlg = makeSSHDialog();
|
||||
sshDlg.show(here, mode || 'term', into);
|
||||
}
|
||||
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="f saved-row"><span>Сохранённый хост</span><div class="frow"><select name="saved" class="grow"><option value="">новый хост</option></select>' +
|
||||
'<button type="button" class="btn ghost small icon close x" data-delsaved title="Удалить из сохранённых" aria-label="Удалить из сохранённых" hidden>' + X + '</button></div></div>' +
|
||||
'<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 auth-row"><span>Вход</span><div class="seg" role="tablist">' +
|
||||
'<button type="button" data-auth="password" class="on">Пароль</button><button type="button" data-auth="key">Ключ</button><button type="button" data-auth="agent">Ключи агента</button></div></div>' +
|
||||
'<div class="auth" data-for="password"><input type="password" name="password" placeholder="пароль" autocomplete="off"></div>' +
|
||||
'<div class="auth" data-for="key" hidden>' +
|
||||
'<textarea name="key" rows="5" spellcheck="false" placeholder="-----BEGIN OPENSSH PRIVATE KEY----- …вставьте ключ или загрузите файл"></textarea>' +
|
||||
'<div class="frow"><button type="button" class="btn ghost small" data-load>Загрузить файл…</button><span class="muted keyname"></span>' +
|
||||
'<input type="file" hidden></div>' +
|
||||
'<input type="password" name="passphrase" placeholder="фраза ключа, если есть" autocomplete="off"></div>' +
|
||||
'<p class="auth muted" data-for="agent" hidden>Агент войдёт своими ключами из ~/.ssh (id_ed25519, id_ecdsa, id_rsa) без фразы.</p>' +
|
||||
'<label class="chk save-row"><input type="checkbox" name="save"> Сохранить хост и вход - на сервере, зашифровано, только для вас</label>' +
|
||||
'<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');
|
||||
// say: a line under the form - red for what went wrong, quiet for what goes on.
|
||||
const say = (t, going) => { msg.textContent = t; msg.classList.toggle('going', !!going); };
|
||||
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) { say('файл великоват для ключа'); return; }
|
||||
fl.text().then(t => { f.key.value = t.trim() + '\n'; d.querySelector('.keyname').textContent = fl.name; say(''); });
|
||||
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 = [], saved = [];
|
||||
// A saved host: its address shows, the way in stays on the server.
|
||||
const useSaved = () => {
|
||||
const h = saved.find(x => String(x.id) === f.saved.value);
|
||||
d.querySelector('[data-delsaved]').hidden = !h;
|
||||
d.querySelectorAll('.auth-row, .auth, .save-row').forEach(el => { el.style.display = h ? 'none' : ''; });
|
||||
if (!h) { setAuth(auth); return; }
|
||||
f.host.value = h.host; f.port.value = h.port; f.user.value = h.user;
|
||||
};
|
||||
f.saved.onchange = useSaved;
|
||||
const loadSaved = () => fetch('/api/panel/ssh/hosts', { cache: 'no-store' }).then(r => r.json()).then(list => {
|
||||
saved = list || [];
|
||||
const keep = f.saved.value;
|
||||
f.saved.innerHTML = '<option value="">новый хост</option>' + saved.map(h => '<option value="' + h.id + '">' +
|
||||
String(h.label).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])) + '</option>').join('');
|
||||
f.saved.value = saved.some(h => String(h.id) === keep) ? keep : '';
|
||||
d.querySelector('.saved-row').hidden = !saved.length;
|
||||
useSaved();
|
||||
}).catch(() => {});
|
||||
d.querySelector('[data-delsaved]').onclick = async () => {
|
||||
const h = saved.find(x => String(x.id) === f.saved.value);
|
||||
if (!h || !await UI.confirm('Удалить «' + h.label + '» из сохранённых?', { ok: 'Удалить', danger: true })) return;
|
||||
await fetch('/api/panel/ssh/hosts/' + h.id + '/delete', { method: 'POST' });
|
||||
f.saved.value = '';
|
||||
loadSaved();
|
||||
};
|
||||
f.onsubmit = async ev => {
|
||||
ev.preventDefault();
|
||||
const a = agents.find(x => x.id === f.agent.value);
|
||||
if (!a) { say('выберите машину'); return; }
|
||||
const t = { host: f.host.value.trim(), port: Number(f.port.value) || 22, user: f.user.value.trim() };
|
||||
if (f.saved.value) {
|
||||
t.saved = Number(f.saved.value);
|
||||
} else if (auth === 'password') {
|
||||
if (!f.password.value) { say('введите пароль'); f.password.focus(); return; }
|
||||
t.password = f.password.value;
|
||||
} else if (auth === 'key') {
|
||||
if (!/PRIVATE KEY/.test(f.key.value)) { say('вставьте закрытый ключ или загрузите файл'); return; }
|
||||
t.key = f.key.value;
|
||||
if (f.passphrase.value) t.passphrase = f.passphrase.value;
|
||||
}
|
||||
if (!t.saved && f.save.checked) {
|
||||
// Saved first, then used by its id like any saved host.
|
||||
const r = await fetch('/api/panel/ssh/hosts', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(Object.assign({ auth: auth }, t)) }).then(x => x.json()).catch(() => ({ ok: false, error: 'ошибка сети' }));
|
||||
if (!r.ok) { say(r.error || 'не сохранилось'); return; }
|
||||
f.save.checked = false;
|
||||
loadSaved();
|
||||
}
|
||||
try { localStorage.setItem('lpra.ssh.last', JSON.stringify({ agent: a.id, host: t.host, port: t.port, user: t.user, auth: auth, saved: t.saved || 0 })); } catch (e) { /* not kept */ }
|
||||
f.password.value = ''; f.passphrase.value = ''; f.key.value = '';
|
||||
d.querySelector('.keyname').textContent = '';
|
||||
if (sshMode === 'files') {
|
||||
// The machine connects first; the pane opens once it is in.
|
||||
const btn = f.querySelector('button[type=submit]');
|
||||
btn.disabled = true;
|
||||
say('подключение…', true);
|
||||
window.Workspace.sshFiles(a, t, sshInto).then(r => {
|
||||
btn.disabled = false;
|
||||
if (!r.ok) { say(r.error || 'не подключилось'); return; }
|
||||
say('');
|
||||
close();
|
||||
});
|
||||
return;
|
||||
}
|
||||
close(() => openTerminal(a, t));
|
||||
};
|
||||
return {
|
||||
show(here, mode, into) {
|
||||
sshMode = mode;
|
||||
sshInto = into || null;
|
||||
d.querySelector('.dlg-h strong').textContent = mode === 'files' ? 'Файлы по SSH' : 'SSH-подключение';
|
||||
say('');
|
||||
let last = {};
|
||||
try { last = JSON.parse(localStorage.getItem('lpra.ssh.last') || '{}'); } catch (e) { /* none */ }
|
||||
agents = here ? [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 ? here.id : (agents.find(a => a.online !== false) || {}).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 (here && !agents.some(a => a.id === here.id)) agents.unshift(here);
|
||||
fill();
|
||||
}).catch(() => {});
|
||||
if (into) last = { agent: here.id, host: into.ssh.host, port: into.ssh.port, user: into.ssh.user, auth: last.auth, saved: last.saved };
|
||||
f.saved.value = '';
|
||||
loadSaved().then(() => {
|
||||
// The host used last, if it was a saved one and still is.
|
||||
if (last.saved && saved.some(h => h.id === last.saved && h.host === last.host)) { f.saved.value = String(last.saved); useSaved(); }
|
||||
});
|
||||
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');
|
||||
// A file pane on an SSH host lets the machine drop its connection.
|
||||
if (s.src && s.src.close) s.src.close();
|
||||
}));
|
||||
});
|
||||
|
||||
// The view coming into sight: fit the panes, show the status line.
|
||||
document.addEventListener('app:view', ev => {
|
||||
if (ev.detail === 'terminal') { refit(); showStatus(); } else statusEl.textContent = '';
|
||||
});
|
||||
$('empty').hidden = tabs.length > 0;
|
||||
|
||||
const paneTypes = {};
|
||||
// open opens a pane of kind on agent in a new tab and shows the view.
|
||||
function open(kind, agent, opts) {
|
||||
const t = openPane(kind, agent, opts);
|
||||
if (location.pathname !== '/workspace' && window.App) App.go('/workspace');
|
||||
return t;
|
||||
}
|
||||
const in_ = (el, x, y) => { const r = el.getBoundingClientRect(); return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom; };
|
||||
window.Workspace = {
|
||||
paneTypes, desk, touch, open, activate,
|
||||
hasLiveSessions: () => tabs.some(t => t.panes.some(p => p.live())),
|
||||
panes: () => tabs.flatMap(t => t.panes),
|
||||
activePane: () => activePane,
|
||||
renderPane: p => { const t = tabOf(p); if (t) renderTab(t); if (p === activePane) showStatus(); },
|
||||
setActivePane, tabOf, renderTab, showStatus, closePane, startDrag, openSSH,
|
||||
// paneAt, tabAt: the pane of the shown tab, the tab title at a point.
|
||||
paneAt: (x, y) => active && active.panes.find(p => in_(p.el, x, y)),
|
||||
tabAt: (x, y) => document.body.classList.contains('multi') ? tabs.find(t => in_(t.el, x, y)) : null
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,107 @@
|
||||
// Pure helpers of the workspace: quoting paths for a shell, what a dropped
|
||||
// file does where it lands, sizes. No DOM here, so node can test them.
|
||||
(function (root) {
|
||||
'use strict';
|
||||
function shellQuote(p) { return "'" + String(p).replace(/'/g, "'\\''") + "'"; }
|
||||
function shellQuoteAll(ps) { return ps.map(shellQuote).join(' '); }
|
||||
const same = (a, b) => a.kind === b.kind && a.id === b.id;
|
||||
// dropAction says what dropping item on target does: a transfer between
|
||||
// file panes, paths pasted into a terminal of the same place, nothing on
|
||||
// its own folder, or a refusal with the reason.
|
||||
function dropAction(item, target) {
|
||||
if (target.kind === 'term') {
|
||||
return same(item.source, target.source) ? {kind: 'paste'}
|
||||
: {kind: 'deny', reason: 'Нельзя: файл на ' + item.source.label + ', а терминал на ' + target.source.label};
|
||||
}
|
||||
if (same(item.source, target.source) && target.dir === item.dir) return {kind: 'none'};
|
||||
return {kind: 'transfer'};
|
||||
}
|
||||
function fmtSize(n) {
|
||||
if (n < 1024) return n + ' B';
|
||||
if (n < 1048576) return (n / 1024).toFixed(1) + ' K';
|
||||
if (n < 1073741824) return (n / 1048576).toFixed(1) + ' M';
|
||||
return (n / 1073741824).toFixed(1) + ' G';
|
||||
}
|
||||
// scope gives a module timers, requests and listeners that end with it;
|
||||
// once ended it starts nothing new (a request's late answer cannot set a
|
||||
// timer going again).
|
||||
function scope() {
|
||||
let ended = false;
|
||||
const timers = new Set(), offs = [], ac = new AbortController();
|
||||
return {
|
||||
timeout(fn, ms) { if (ended) return 0; const id = setTimeout(() => { timers.delete(id); fn(); }, ms); timers.add(id); return id; },
|
||||
interval(fn, ms) { if (ended) return 0; const id = setInterval(fn, ms); timers.add(id); return id; },
|
||||
clear(id) { clearTimeout(id); clearInterval(id); timers.delete(id); },
|
||||
fetch(url, opt) {
|
||||
const o = Object.assign({credentials: 'same-origin'}, opt || {});
|
||||
// A request with its own signal ends on either.
|
||||
o.signal = o.signal && AbortSignal.any ? AbortSignal.any([o.signal, ac.signal]) : (o.signal || ac.signal);
|
||||
return fetch(url, o);
|
||||
},
|
||||
on(target, ev, fn, opt) { target.addEventListener(ev, fn, opt); offs.push(() => target.removeEventListener(ev, fn, opt)); },
|
||||
signal: ac.signal,
|
||||
end() { ended = true; timers.forEach(id => { clearTimeout(id); clearInterval(id); }); timers.clear(); offs.forEach(f => f()); ac.abort(); }
|
||||
};
|
||||
}
|
||||
|
||||
// parseActions finds what an assistant's answer asks the panel to do:
|
||||
// closed ``` blocks headed run [machine], read|ls [machine:]path or
|
||||
// write [machine:]path (its body the file's text).
|
||||
function parseActions(text) {
|
||||
const out = [], re = /```(run|read|ls|write|search|fetch)([^\n]*)\n([\s\S]*?)```/g;
|
||||
for (let m; (m = re.exec(text));) {
|
||||
const kind = m[1], head = m[2].trim(), body = m[3].replace(/\n$/, '');
|
||||
if (kind === 'run') { out.push({kind, machine: head, path: '', body}); continue; }
|
||||
if (kind === 'search' || kind === 'fetch') { out.push({kind, machine: '', path: '', body: (head + '\n' + body).trim()}); continue; }
|
||||
const c = head.indexOf(':');
|
||||
const machine = c > 0 && !head.startsWith('/') ? head.slice(0, c) : '';
|
||||
out.push({kind, machine, path: machine ? head.slice(c + 1) : head, body: kind === 'write' ? body : ''});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
// md turns an assistant's text (no ``` blocks - those are drawn apart)
|
||||
// into HTML: headings, lists, quotes, bold, italic, `code` and http(s)
|
||||
// links. Everything is escaped first, so nothing else gets through.
|
||||
const escH = v => String(v).replace(/[&<>"]/g, c => ({'&': '&', '<': '<', '>': '>', '"': '"'}[c]));
|
||||
function mdInline(t) {
|
||||
const codes = [];
|
||||
t = t.replace(/`([^`\n]+)`/g, (_, c) => { codes.push(c); return '\u0000' + (codes.length - 1) + '\u0000'; });
|
||||
t = escH(t)
|
||||
.replace(/\*\*([^*\n]+)\*\*/g, '<b>$1</b>')
|
||||
.replace(/(^|[^*\w])\*([^*\n]+)\*(?!\w)/g, '$1<i>$2</i>')
|
||||
.replace(/(^|\W)_([^_\n]+)_(?!\w)/g, '$1<i>$2</i>')
|
||||
.replace(/\[([^\]\n]+)\]\(((?:[^()\s]|\([^()\s]*\))+)\)/g, (_, txt, url) => /^https?:\/\//i.test(url.replace(/&/g, '&'))
|
||||
? '<a href="' + url + '" target="_blank" rel="noopener">' + txt + '</a>' : txt);
|
||||
return t.replace(/\u0000(\d+)\u0000/g, (_, i) => '<code>' + escH(codes[+i]) + '</code>');
|
||||
}
|
||||
function md(text) {
|
||||
const out = [];
|
||||
let para = [], list = null;
|
||||
const flush = () => {
|
||||
if (para.length) out.push('<p>' + para.map(mdInline).join('<br>') + '</p>');
|
||||
para = [];
|
||||
if (list) { out.push('<' + list.tag + '>' + list.items.map(x => '<li>' + mdInline(x) + '</li>').join('') + '</' + list.tag + '>'); list = null; }
|
||||
};
|
||||
for (const line of String(text).split('\n')) {
|
||||
let m;
|
||||
if (!line.trim()) { flush(); continue; }
|
||||
if ((m = /^#{1,6}\s+(.*)$/.exec(line))) { flush(); out.push('<h4>' + mdInline(m[1]) + '</h4>'); continue; }
|
||||
if (/^\s*(-{3,}|\*{3,})\s*$/.test(line)) { flush(); out.push('<hr>'); continue; }
|
||||
if ((m = /^>\s?(.*)$/.exec(line))) { flush(); out.push('<blockquote>' + mdInline(m[1]) + '</blockquote>'); continue; }
|
||||
const ul = /^\s*[-*•]\s+(.*)$/.exec(line), ol = /^\s*\d+[.)]\s+(.*)$/.exec(line);
|
||||
if (ul || ol) {
|
||||
const tag = ul ? 'ul' : 'ol';
|
||||
if (para.length || (list && list.tag !== tag)) flush();
|
||||
if (!list) list = {tag, items: []};
|
||||
list.items.push((ul || ol)[1]);
|
||||
continue;
|
||||
}
|
||||
if (list) flush();
|
||||
para.push(line);
|
||||
}
|
||||
flush();
|
||||
return out.join('');
|
||||
}
|
||||
const api = {shellQuote, shellQuoteAll, dropAction, fmtSize, scope, parseActions, md};
|
||||
if (typeof module !== 'undefined') module.exports = api; else root.WSUtil = api;
|
||||
})(this);
|
||||
@@ -0,0 +1,87 @@
|
||||
'use strict';
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const U = require('./wsutil.js');
|
||||
|
||||
test('shellQuote keeps a path one literal word', () => {
|
||||
assert.strictEqual(U.shellQuote('/srv/a b'), "'/srv/a b'");
|
||||
assert.strictEqual(U.shellQuote("/x/it's"), "'/x/it'\\''s'");
|
||||
assert.strictEqual(U.shellQuote('/x/$HOME;rm'), "'/x/$HOME;rm'");
|
||||
assert.strictEqual(U.shellQuoteAll(['/a', '/b c']), "'/a' '/b c'");
|
||||
});
|
||||
|
||||
const m1 = {kind: 'agent', id: 'm1', label: 'web'};
|
||||
const m2 = {kind: 'agent', id: 'm2', label: 'db'};
|
||||
const s1 = {kind: 'ssh', id: 'm1|root@h:22', label: 'root@h'};
|
||||
const item = {source: m1, paths: ['/etc/x'], dir: '/etc'};
|
||||
|
||||
test('dropAction: files into a file pane is a transfer', () => {
|
||||
assert.deepStrictEqual(U.dropAction(item, {kind: 'files', source: m2, dir: '/tmp'}), {kind: 'transfer'});
|
||||
assert.deepStrictEqual(U.dropAction(item, {kind: 'folder', source: m1, dir: '/var'}), {kind: 'transfer'});
|
||||
});
|
||||
test('dropAction: into its own folder does nothing', () => {
|
||||
assert.deepStrictEqual(U.dropAction(item, {kind: 'files', source: m1, dir: '/etc'}), {kind: 'none'});
|
||||
});
|
||||
test('dropAction: terminal of the same place pastes, of another is denied', () => {
|
||||
assert.deepStrictEqual(U.dropAction(item, {kind: 'term', source: m1}), {kind: 'paste'});
|
||||
const d = U.dropAction(item, {kind: 'term', source: m2});
|
||||
assert.strictEqual(d.kind, 'deny');
|
||||
assert.strictEqual(d.reason, 'Нельзя: файл на web, а терминал на db');
|
||||
assert.strictEqual(U.dropAction({source: s1, paths: ['/a'], dir: '/'}, {kind: 'term', source: s1}).kind, 'paste');
|
||||
});
|
||||
test('fmtSize', () => {
|
||||
assert.strictEqual(U.fmtSize(10), '10 B');
|
||||
assert.strictEqual(U.fmtSize(2048), '2.0 K');
|
||||
});
|
||||
|
||||
test('dropAction: other SSH user on the same host is another place', () => {
|
||||
const a = {kind: 'ssh', id: 'm1|root@h:22', label: 'root@h'};
|
||||
const b = {kind: 'ssh', id: 'm1|bob@h:22', label: 'bob@h'};
|
||||
assert.strictEqual(U.dropAction({source: a, paths: ['/x'], dir: '/'}, {kind: 'term', source: b}).kind, 'deny');
|
||||
});
|
||||
|
||||
test('scope: nothing new runs after it ended', async () => {
|
||||
const s = U.scope();
|
||||
let n = 0;
|
||||
s.end();
|
||||
s.timeout(() => n++, 1);
|
||||
s.interval(() => n++, 1);
|
||||
await new Promise(r => setTimeout(r, 20));
|
||||
assert.strictEqual(n, 0);
|
||||
});
|
||||
|
||||
test('parseActions: run, read, ls and write blocks with or without a machine', () => {
|
||||
const text = 'Сделаю так:\n```run web\nuptime\n```\nи\n```read db:/etc/hosts\n```\n```ls /var/log\n```\n```write web:/tmp/a b.txt\nline 1\nline 2\n```\n```bash\nnot an action\n```\n```run\ndf -h\n```';
|
||||
assert.deepStrictEqual(U.parseActions(text), [
|
||||
{kind: 'run', machine: 'web', path: '', body: 'uptime'},
|
||||
{kind: 'read', machine: 'db', path: '/etc/hosts', body: ''},
|
||||
{kind: 'ls', machine: '', path: '/var/log', body: ''},
|
||||
{kind: 'write', machine: 'web', path: '/tmp/a b.txt', body: 'line 1\nline 2'},
|
||||
{kind: 'run', machine: '', path: '', body: 'df -h'}
|
||||
]);
|
||||
// A block still being written is not an action yet.
|
||||
assert.strictEqual(U.parseActions('```run\nls').length, 0);
|
||||
});
|
||||
|
||||
test('md: the common marks, everything else escaped', () => {
|
||||
assert.strictEqual(U.md('**жирный** и *курсив*, `ls -la <x>`'),
|
||||
'<p><b>жирный</b> и <i>курсив</i>, <code>ls -la <x></code></p>');
|
||||
assert.strictEqual(U.md('# Итог\n- раз\n- два\n\n1. первое\n2. второе'),
|
||||
'<h4>Итог</h4><ul><li>раз</li><li>два</li></ul><ol><li>первое</li><li>второе</li></ol>');
|
||||
assert.strictEqual(U.md('[док](https://ex.com/a?b=1&c=2) и [плохо](javascript:alert(1))'),
|
||||
'<p><a href="https://ex.com/a?b=1&c=2" target="_blank" rel="noopener">док</a> и плохо</p>');
|
||||
assert.strictEqual(U.md('> цитата\n\nстрока 1\nстрока 2'), '<blockquote>цитата</blockquote><p>строка 1<br>строка 2</p>');
|
||||
assert.strictEqual(U.md('<script>x</script>'), '<p><script>x</script></p>');
|
||||
// Inside code nothing is marked up.
|
||||
assert.strictEqual(U.md('`**a**`'), '<p><code>**a**</code></p>');
|
||||
});
|
||||
|
||||
test('parseActions: search blocks', () => {
|
||||
assert.deepStrictEqual(U.parseActions('Ищу:\n```search\nnginx 1.27 changelog\n```'),
|
||||
[{kind: 'search', machine: '', path: '', body: 'nginx 1.27 changelog'}]);
|
||||
});
|
||||
|
||||
test('parseActions: fetch blocks', () => {
|
||||
assert.deepStrictEqual(U.parseActions('```fetch\nhttps://go.dev/doc/go1.24\n```'),
|
||||
[{kind: 'fetch', machine: '', path: '', body: 'https://go.dev/doc/go1.24'}]);
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
{{define "app.html"}}<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<title>LPRA</title>
|
||||
{{template "head" .}}
|
||||
<link rel="stylesheet" href="{{asset "xterm.css"}}">
|
||||
</head>
|
||||
<body class="app">
|
||||
{{template "topbar" .}}
|
||||
<main id="app" data-view="{{.View}}">
|
||||
<section class="view" data-view="machines"{{if ne .View "dashboard"}} hidden{{end}}>{{if eq .View "dashboard"}}{{.Body}}{{end}}</section>
|
||||
<section class="view view-term" data-view="terminal"{{if ne .View "terminal"}} hidden{{end}}>
|
||||
<div class="tabs" id="tabs">
|
||||
<div class="tablist" id="tablist"><div class="tabmark" id="tabmark" hidden></div></div>
|
||||
<button type="button" class="tab-add" id="tabadd" title="Новый терминал" aria-label="Новый терминал">+</button>
|
||||
</div>
|
||||
<div class="area" id="area">
|
||||
<button type="button" class="float-add" id="floatadd" title="Новый терминал" aria-label="Новый терминал">+</button>
|
||||
<div class="empty" id="empty" hidden>Нет открытых терминалов<button type="button" id="emptyadd">Новый терминал</button></div>
|
||||
<div class="hint" id="hint" hidden></div>
|
||||
</div>
|
||||
<div class="addmenu" id="addmenu" hidden></div>
|
||||
<div class="selmenu" id="selmenu" hidden>
|
||||
<button type="button" data-a="copy" title="Копировать" aria-label="Копировать">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"/></svg>
|
||||
</button>
|
||||
<button type="button" class="btn ghost" data-a="paste" title="Вставить" aria-label="Вставить">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="8" y="2" width="8" height="4" rx="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/></svg>
|
||||
</button>
|
||||
<button type="button" class="btn ghost" data-a="all" title="Выделить всё" aria-label="Выделить всё">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 3a2 2 0 0 0-2 2M19 3a2 2 0 0 1 2 2M21 19a2 2 0 0 1-2 2M5 21a2 2 0 0 1-2-2M9 3h1M9 21h1M14 3h1M14 21h1M3 9v1M21 9v1M3 14v1M21 14v1"/><rect x="7" y="7" width="10" height="10" rx="1"/></svg>
|
||||
</button>
|
||||
<button type="button" class="btn ghost close" data-a="close" title="Закрыть" aria-label="Закрыть">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="keys" id="keys">
|
||||
<button type="button" data-k="esc">ESC</button>
|
||||
<button type="button" data-k="/">/</button>
|
||||
<button type="button" data-k="-">-</button>
|
||||
<button type="button" data-k="home">HOME</button>
|
||||
<button type="button" data-k="up">↑</button>
|
||||
<button type="button" data-k="end">END</button>
|
||||
<button type="button" data-k="pgup">PGUP</button>
|
||||
<button type="button" data-k="tab">TAB</button>
|
||||
<button type="button" data-k="ctrl" data-mod="ctrl">CTRL</button>
|
||||
<button type="button" data-k="alt" data-mod="alt">ALT</button>
|
||||
<button type="button" data-k="left">←</button>
|
||||
<button type="button" data-k="down">↓</button>
|
||||
<button type="button" data-k="right">→</button>
|
||||
<button type="button" data-k="pgdn">PGDN</button>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
<section class="view" data-view="settings"{{if ne .View "settings"}} hidden{{end}}>{{if eq .View "settings"}}{{.Body}}{{end}}</section>
|
||||
</main>
|
||||
<dialog id="viewer" tabindex="-1">
|
||||
<div class="dlg-h">
|
||||
<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="vhex" 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>
|
||||
<div class="vmsg muted" id="vmsg"></div>
|
||||
</div>
|
||||
<div class="vbody">
|
||||
<div class="find" id="find" hidden>
|
||||
<div class="fin"><input type="text" id="fq" placeholder="Найти" spellcheck="false" autocomplete="off"><span class="cnt" id="fcnt"></span></div>
|
||||
<div class="fbtns">
|
||||
<button type="button" class="tg" id="fcase" title="Учитывать регистр">Aa</button>
|
||||
<button type="button" class="tg" id="fre" title="Регулярное выражение">.*</button>
|
||||
<button type="button" class="btn ghost" id="fprev" title="Предыдущее (Shift+Enter)" aria-label="Предыдущее">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12"><path d="M6 2.5 11 9.5H1z" fill="currentColor"/></svg>
|
||||
</button>
|
||||
<button type="button" class="btn ghost" id="fnext" title="Следующее (Enter)" aria-label="Следующее">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12"><path d="M6 9.5 1 2.5h10z" fill="currentColor"/></svg>
|
||||
</button>
|
||||
<button type="button" class="btn ghost close" id="fclose" title="Закрыть поиск (Esc)" aria-label="Закрыть поиск">
|
||||
<svg width="13" height="13" 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="fin" id="frow2"><input type="text" id="frep" placeholder="Заменить на" spellcheck="false" autocomplete="off"></div>
|
||||
<div class="fbtns" id="frow2b">
|
||||
<button type="button" class="btn ghost" id="frepone">Заменить</button>
|
||||
<button type="button" class="btn ghost" id="frepall">Все</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="vcode" hidden></div>
|
||||
<div class="vmedia" id="vmedia" hidden></div>
|
||||
<div class="vdoc" id="vdoc" hidden></div>
|
||||
<div class="hexed" id="vhexed" tabindex="0" hidden><div id="hxsp"></div><div class="hx-rows" id="hxrows"></div></div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="updlg" tabindex="-1">
|
||||
<div class="dlg-h">
|
||||
<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>
|
||||
</div>
|
||||
<div class="dlg-b" id="updlg-list"></div>
|
||||
</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----- …вставьте ключ или загрузите файл"></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>
|
||||
|
||||
<dialog id="machinedlg" class="machinedlg" tabindex="-1"><div class="dlg-h"><strong class="md-title" style="flex:1"></strong>
|
||||
<button type="button" class="btn ghost small icon close x md-close" aria-label="Закрыть"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button></div>
|
||||
<div class="frag-area"></div></dialog>
|
||||
<div class="toast" id="toast"></div>
|
||||
<script>window.APP = {admin: {{.IsAdmin}}, open: {{.Open}}, dialog: {{.Dialog}}, editLimit: {{.EditLimit}}, prefs: {{.Prefs}}};</script>
|
||||
<script src="{{asset "wsutil.js"}}"></script>
|
||||
<script src="{{asset "ui.js"}}"></script>
|
||||
<script src="{{asset "prefs.js"}}"></script>
|
||||
<script src="{{asset "xterm.js"}}"></script>
|
||||
<script src="{{asset "addon-fit.js"}}"></script>
|
||||
<script src="{{asset "app.js"}}"></script>
|
||||
<script src="{{asset "workspace.js"}}"></script>
|
||||
<script src="{{asset "termpane.js"}}"></script>
|
||||
<script src="{{asset "codemirror/codemirror.js"}}"></script>
|
||||
<script src="{{asset "docview.js"}}"></script>
|
||||
<script src="{{asset "dbedit.js"}}"></script>
|
||||
<script src="{{asset "fileviews.js"}}"></script>
|
||||
<script src="{{asset "filepane.js"}}"></script>
|
||||
<script src="{{asset "dnd.js"}}"></script>
|
||||
<script src="{{asset "pages.js"}}"></script>
|
||||
<script src="{{asset "users.js"}}"></script>
|
||||
<script src="{{asset "account.js"}}"></script>
|
||||
<script src="{{asset "machine.js"}}"></script>
|
||||
<script src="{{asset "ai.js"}}"></script>
|
||||
<script>App.start();</script>
|
||||
</body>
|
||||
</html>{{end}}
|
||||
@@ -1,27 +1,9 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Машины - LPRA</title>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand">LPRA · Удалённый доступ</div>
|
||||
<nav>
|
||||
<a href="/">Машины</a>
|
||||
<a href="/settings">Настройки</a>
|
||||
<form method="post" action="/logout" style="display:inline; margin-left:22px">
|
||||
<button class="btn ghost small" type="submit">Выйти</button>
|
||||
</form>
|
||||
</nav>
|
||||
</header>
|
||||
{{define "dashboard-body"}}<div class="page" data-module="dashboard">
|
||||
<div class="wrap">
|
||||
<h1>Машины-клиенты</h1>
|
||||
<div class="card">
|
||||
{{if .Agents}}
|
||||
<table class="stack">
|
||||
<table class="stack machines">
|
||||
<thead><tr><th>Статус</th><th>Имя</th><th>Hostname</th><th>ОС / Арх</th><th>Активность</th><th>Действия</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Agents}}
|
||||
@@ -33,28 +15,28 @@
|
||||
<td class="muted nowrap seen" data-l="Активность">{{.Seen}}</td>
|
||||
<td class="actions">
|
||||
<div class="row" style="gap:6px">
|
||||
<a class="btn small icon" href="/agents/{{.ID}}/terminal" data-tip="Терминал" aria-label="Терминал">
|
||||
<a class="btn small icon" href="/agents/{{.ID}}/terminal" data-open="term" data-id="{{.ID}}" data-name="{{.Name}}" data-host="{{.Hostname}}" data-user="{{.TerminalUser}}" data-tip="Терминал" aria-label="Терминал">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>
|
||||
</a>
|
||||
<a class="btn ghost small icon" href="/agents/{{.ID}}/files" data-tip="Файлы" aria-label="Файлы">
|
||||
<a class="btn ghost small icon" href="/agents/{{.ID}}/files" data-open="files" data-id="{{.ID}}" data-name="{{.Name}}" data-host="{{.Hostname}}" data-user="{{.TerminalUser}}" data-tip="Файлы" aria-label="Файлы">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
|
||||
</a>
|
||||
<a class="btn ghost small icon" href="/agents/{{.ID}}/forwards" data-tip="Порты" aria-label="Порты">
|
||||
<a class="btn ghost small icon" href="/agents/{{.ID}}/forwards" data-dialog="forwards" data-id="{{.ID}}" data-name="{{.Name}}" data-tip="Порты" aria-label="Порты">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>
|
||||
</a>
|
||||
<a class="btn ghost small icon" href="/agents/{{.ID}}/status" data-tip="Статус" aria-label="Статус">
|
||||
<a class="btn ghost small icon" href="/agents/{{.ID}}/status" data-dialog="status" data-id="{{.ID}}" data-name="{{.Name}}" data-tip="Статус" aria-label="Статус">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
|
||||
</a>
|
||||
<a class="btn ghost small icon" href="/agents/{{.ID}}/edit" data-tip="Изменить" aria-label="Изменить">
|
||||
<a class="btn ghost small icon" href="/agents/{{.ID}}/edit" data-dialog="edit" data-id="{{.ID}}" data-name="{{.Name}}" data-tip="Изменить" aria-label="Изменить">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z"/></svg>
|
||||
</a>
|
||||
<form method="post" action="/agents/{{.ID}}/delete" style="display:inline"
|
||||
onsubmit="return confirm('Удалить машину {{.Name}}? Сервис на ней продолжит работать, но перестанет быть виден. Чтобы снова подключиться - переустановите агента.')">
|
||||
{{if $.IsAdmin}}<form method="post" action="/agents/{{.ID}}/delete" style="display:inline"
|
||||
data-confirm="Удалить машину {{.Name}}? Сервис на ней продолжит работать, но перестанет быть виден. Чтобы снова подключиться - переустановите агента." data-danger>
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<button class="btn danger small icon" type="submit" data-tip="Удалить" aria-label="Удалить">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>
|
||||
</button>
|
||||
</form>
|
||||
</form>{{end}}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -62,43 +44,10 @@
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="muted">Пока нет подключённых машин. Добавьте первую в разделе
|
||||
{{if .IsAdmin}}<p class="muted">Пока нет подключённых машин. Добавьте первую в разделе
|
||||
<a href="/settings">Настройки</a> - там одна команда для установки.</p>
|
||||
{{else}}<p class="muted">Вам пока не выдали ни одной машины - попросите администратора.</p>{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
// Keep the status column live; nothing is asked while the tab is hidden.
|
||||
(function () {
|
||||
var rows = {};
|
||||
document.querySelectorAll('tr[data-agent]').forEach(function (tr) { rows[tr.dataset.agent] = tr; });
|
||||
var timer = null, busy = false;
|
||||
function schedule() {
|
||||
clearTimeout(timer);
|
||||
if (!document.hidden) timer = setTimeout(load, 5000);
|
||||
}
|
||||
function load() {
|
||||
if (busy || document.hidden) return;
|
||||
busy = true;
|
||||
fetch('/api/panel/agents/presence', {credentials: 'same-origin'})
|
||||
.then(function (r) { return r.ok ? r.json() : []; })
|
||||
.then(function (list) {
|
||||
list.forEach(function (a) {
|
||||
var tr = rows[a.id];
|
||||
if (!tr) return;
|
||||
tr.querySelector('.dot').className = 'dot ' + (a.online ? 'on' : 'off');
|
||||
tr.querySelector('.state').textContent = a.online ? 'online' : 'offline';
|
||||
tr.querySelector('.seen').textContent = a.seen;
|
||||
});
|
||||
})
|
||||
.catch(function () {})
|
||||
.then(function () { busy = false; schedule(); });
|
||||
}
|
||||
document.addEventListener('visibilitychange', function () {
|
||||
if (document.hidden) clearTimeout(timer); else load();
|
||||
});
|
||||
if (Object.keys(rows).length) schedule();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</div>{{end}}
|
||||
@@ -1,22 +1,4 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Изменить машину - LPRA</title>
|
||||
<link rel="stylesheet" href="/static/app.css?v={{.ServerVersion}}-2">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand">LPRA · Изменить машину</div>
|
||||
<nav>
|
||||
<a href="/">← Машины</a>
|
||||
<a href="/agents/{{.Agent.ID}}/terminal">Терминал</a>
|
||||
<a href="/agents/{{.Agent.ID}}/files">Файлы</a>
|
||||
<a href="/agents/{{.Agent.ID}}/forwards">Порты</a>
|
||||
<a href="/agents/{{.Agent.ID}}/status">Статус</a>
|
||||
</nav>
|
||||
</header>
|
||||
{{define "edit-body"}}<div class="page" data-module="edit">
|
||||
<div class="wrap">
|
||||
<h1>{{.Agent.Name}}</h1>
|
||||
|
||||
@@ -72,9 +54,29 @@
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Связь с сервером</h2>
|
||||
<p class="muted">
|
||||
Сейчас: {{if not .Online}}<span>офлайн</span>{{else if .OnWS}}<span style="color:var(--ok)">WebSocket</span>{{else}}<span>long polling</span>{{end}}{{if and .Online .ViaDirect}} <span class="muted">(direct)</span>{{end}}
|
||||
</p>
|
||||
<form method="post" action="/agents/{{.Agent.ID}}/transport" class="row">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<select name="transport">
|
||||
<option value="auto" {{if eq .Transport "auto"}}selected{{end}}>Авто: WebSocket, а если не выходит - long polling</option>
|
||||
<option value="ws" {{if eq .Transport "ws"}}selected{{end}}>Только WebSocket</option>
|
||||
<option value="lp" {{if eq .Transport "lp"}}selected{{end}}>Только long polling</option>
|
||||
</select>
|
||||
<button type="submit">Сохранить</button>
|
||||
</form>
|
||||
<p class="muted" style="font-size:13px; margin-top:10px">WebSocket держит одно соединение: команды и терминал идут по нему без отдельного запроса на каждое нажатие.
|
||||
В режиме «Авто» клиент, потеряв WebSocket, переходит на long polling и раз в минуту пробует вернуться.
|
||||
Нужен клиент 0.1.0+ и прокси, пропускающий WebSocket (nginx: заголовки Upgrade и Connection).</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Версия клиента</h2>
|
||||
{{if .Updated}}<div class="err" style="background:#1d3a24;border-color:#2b6b3a;color:#b3ffcb">Команда обновления отправлена. Клиент скачает свежий бинарник и перезапустится.</div>{{end}}
|
||||
{{if .Updated}}<div class="err" style="background:#1d3a24;border-color:#2b6b3a;color:#b3ffcb">Клиент {{if .UpdateMsg}}{{.UpdateMsg}}{{else}}обновлён и перезапускается{{end}}. Через несколько секунд он вернётся с новой версией.</div>{{end}}
|
||||
{{if .UpdateErr}}<div class="err">Обновление не вышло: {{.UpdateErr}}</div>{{end}}
|
||||
<p class="muted">
|
||||
Клиент: <code>{{if .Agent.Version}}{{.Agent.Version}}{{else}}неизвестно{{end}}</code>
|
||||
· Сервер: <code>{{.ServerVersion}}</code>
|
||||
@@ -82,7 +84,7 @@
|
||||
</p>
|
||||
{{if .Outdated}}
|
||||
<form method="post" action="/agents/{{.Agent.ID}}/update"
|
||||
onsubmit="return confirm('Обновить клиента до версии {{.ServerVersion}}? Бинарник будет заменён, сервис перезапущен (без удаления).')">
|
||||
data-confirm="Обновить клиента до версии {{.ServerVersion}}? Бинарник будет заменён, сервис перезапущен (без удаления)." data-danger>
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<button type="submit">Обновить клиента</button>
|
||||
</form>
|
||||
@@ -91,16 +93,15 @@
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
{{if .IsAdmin}}<div class="card">
|
||||
<h2>Удаление</h2>
|
||||
<p class="muted">Удаляет машину из панели. Сервис на самой машине продолжит работать,
|
||||
но перестанет быть виден; чтобы подключиться снова - переустановите агента.</p>
|
||||
<form method="post" action="/agents/{{.Agent.ID}}/delete"
|
||||
onsubmit="return confirm('Удалить машину {{.Agent.Name}}?')">
|
||||
data-confirm="Удалить машину {{.Agent.Name}}?" data-danger>
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<button class="btn danger" type="submit">Удалить машину</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>{{end}}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
</div>{{end}}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,10 +1,4 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Порты · {{.Agent.Name}} - LPRA</title>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
{{define "forwards-body"}}<div class="page" data-module="forwards" data-panel-port="{{.PanelPort}}" data-tunnel-port="{{.TunnelPort}}">
|
||||
<style>
|
||||
/* One line: fixed narrow fields, the label takes what is left. */
|
||||
.frow { display:flex; align-items:flex-end; gap:12px; flex-wrap:wrap; }
|
||||
@@ -34,18 +28,6 @@
|
||||
.under { margin-right:0; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand">LPRA · Порты - {{.Agent.Name}} <span class="muted">({{.Agent.Hostname}})</span></div>
|
||||
<nav>
|
||||
<a href="/">← Машины</a>
|
||||
<a href="/agents/{{.Agent.ID}}/terminal">Терминал</a>
|
||||
<a href="/agents/{{.Agent.ID}}/files">Файлы</a>
|
||||
<a href="/agents/{{.Agent.ID}}/status">Статус</a>
|
||||
<a href="/agents/{{.Agent.ID}}/edit">Изменить</a>
|
||||
</nav>
|
||||
</header>
|
||||
<div class="wrap">
|
||||
<h1>Проброс портов</h1>
|
||||
<p class="muted">Порт сервиса на машине-клиенте становится доступен на порту хоста.
|
||||
@@ -56,7 +38,7 @@
|
||||
<h2>Правила</h2>
|
||||
{{if .Rules}}
|
||||
<table class="stack">
|
||||
<thead><tr><th>Тип</th><th>Хост (порт)</th><th>→ Клиент (порт)</th><th>Слушать на</th><th>Метка</th><th></th></tr></thead>
|
||||
<thead><tr><th>Тип</th><th>Хост (порт)</th><th>→ Клиент (порт)</th><th>Слушать на</th><th>Метка</th><th style="text-align:right">Действие</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Rules}}
|
||||
<tr>
|
||||
@@ -67,7 +49,7 @@
|
||||
<td class="lead">{{.Label}}</td>
|
||||
<td class="actions" style="text-align:right">
|
||||
<form method="post" action="/forwards/{{.ID}}/delete" style="display:inline"
|
||||
onsubmit="return confirm('Удалить правило?')">
|
||||
data-confirm="Удалить правило?" data-danger>
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<button class="btn danger small" type="submit">Удалить</button>
|
||||
</form>
|
||||
@@ -133,15 +115,4 @@
|
||||
<p class="muted" style="font-size:13px">Для tcp/udp агент держит исходящее соединение к <code>host:{{.TunnelPort}}</code>; несколько клиентов и портов мультиплексируются на нём.</p>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
var notes = {
|
||||
http: 'Трафик идёт через порт панели {{.PanelPort}} (long-poll). Порт {{.TunnelPort}} не требуется.',
|
||||
tcp: 'Требуется открытый порт {{.TunnelPort}}/tcp на хосте. Самая низкая задержка.',
|
||||
udp: 'Требуется открытый порт {{.TunnelPort}}/udp на хосте.'
|
||||
};
|
||||
var sel = document.getElementById('proto'), note = document.getElementById('note');
|
||||
function upd(){ note.textContent = notes[sel.value] || ''; }
|
||||
sel.onchange = upd; upd();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</div>{{end}}
|
||||
@@ -0,0 +1,17 @@
|
||||
{{define "head"}}<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="{{asset "codemirror/codemirror.css"}}">
|
||||
<link rel="stylesheet" href="{{asset "app.css"}}">
|
||||
<script src="{{asset "pin.js"}}"></script>
|
||||
<script src="{{asset "scroll.js"}}" defer></script>{{end}}
|
||||
|
||||
{{define "topbar"}}<header class="topbar">
|
||||
<div class="brand"><a class="home" href="/" title="К списку машин">LPRA</a> · <span id="brandtitle">Удалённый доступ</span></div>
|
||||
<nav>
|
||||
<a href="/" data-nav="machines">Машины</a>
|
||||
<a href="/workspace" data-nav="terminal">WorkSpace</a>
|
||||
{{if .IsAdmin}}<a href="/settings" data-nav="settings">Настройки</a>{{end}}
|
||||
<span id="status" class="muted"></span>
|
||||
<button type="button" class="btn ghost icon acct-btn" id="acct" title="Аккаунт" aria-label="Аккаунт"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/></svg></button>
|
||||
</nav>
|
||||
</header>{{end}}
|
||||
@@ -4,21 +4,67 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Вход - LPRA</title>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
<link rel="stylesheet" href="{{asset "app.css"}}">
|
||||
</head>
|
||||
<body>
|
||||
<div class="center">
|
||||
<div class="card">
|
||||
<h1>Вход в панель</h1>
|
||||
{{if .Error}}<div class="err">{{.Error}}</div>{{end}}
|
||||
{{if .Error}}<div class="err" id="err">{{.Error}}</div>{{else}}<div class="err" id="err" hidden></div>{{end}}
|
||||
<form method="post" action="/login">
|
||||
<label>Имя пользователя</label>
|
||||
<input type="text" name="username" autofocus required>
|
||||
<label>Пароль</label>
|
||||
<input type="password" name="password" required>
|
||||
<div style="margin-top:18px"><button type="submit">Войти</button></div>
|
||||
<label for="u">Имя пользователя</label>
|
||||
<input type="text" id="u" name="username" value="{{.Username}}" autocomplete="username webauthn" autofocus required>
|
||||
<label for="pw">Пароль</label>
|
||||
<input type="password" id="pw" name="password" autocomplete="current-password" required>
|
||||
<div style="margin-top:18px"><button type="submit" style="width:100%">Войти</button></div>
|
||||
</form>
|
||||
<div class="or" id="or" hidden>или</div>
|
||||
<button type="button" class="keybtn btn ghost" id="keylogin" hidden><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="7.5" cy="15.5" r="4.5"/><path d="m21 2-9.6 9.6M15.5 7.5l3 3L22 7l-3-3"/></svg>Войти с ключом доступа</button>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
// Sign in with a passkey: with the name typed, its keys are offered; without
|
||||
// it, the device offers the keys it keeps for this site.
|
||||
(function () {
|
||||
if (!(window.PublicKeyCredential && navigator.credentials && window.isSecureContext)) return;
|
||||
var btn = document.getElementById('keylogin'), err = document.getElementById('err');
|
||||
btn.hidden = false;
|
||||
document.getElementById('or').hidden = false;
|
||||
var enc = function (buf) {
|
||||
var s = '', b = new Uint8Array(buf);
|
||||
for (var i = 0; i < b.length; i++) s += String.fromCharCode(b[i]);
|
||||
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
};
|
||||
var dec = function (str) {
|
||||
var s = atob(str.replace(/-/g, '+').replace(/_/g, '/') + '==='.slice((str.length + 3) % 4)), b = new Uint8Array(s.length);
|
||||
for (var i = 0; i < s.length; i++) b[i] = s.charCodeAt(i);
|
||||
return b.buffer;
|
||||
};
|
||||
function show(t) { err.textContent = t; err.hidden = !t; }
|
||||
btn.onclick = function () {
|
||||
btn.disabled = true;
|
||||
show('');
|
||||
fetch('/api/login/key/begin', {method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({username: document.getElementById('u').value})})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
var o = r.options;
|
||||
o.challenge = dec(o.challenge);
|
||||
o.allowCredentials.forEach(function (c) { c.id = dec(c.id); });
|
||||
return navigator.credentials.get({publicKey: o});
|
||||
})
|
||||
.then(function (c) {
|
||||
return fetch('/api/login/key/finish', {method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({id: enc(c.rawId), clientDataJSON: enc(c.response.clientDataJSON),
|
||||
authenticatorData: enc(c.response.authenticatorData), signature: enc(c.response.signature)})})
|
||||
.then(function (r) { return r.json(); });
|
||||
})
|
||||
.then(function (r) { if (!r.ok) throw new Error(r.error); location.href = '/'; })
|
||||
.catch(function (e) { show(e && e.name === 'NotAllowedError' ? 'Вход по ключу отменён.' : (e && e.message) || 'Не вышло.'); })
|
||||
.then(function () { btn.disabled = false; });
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loaded 100 of 105 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user