Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
916a49958b | ||
|
|
dde85031aa | ||
|
|
dd1244a169 | ||
|
|
c72fcfebf3 | ||
|
|
054fc97da8 | ||
|
|
114f8b1e1b | ||
|
|
a1604206ee | ||
|
|
a107745355 | ||
|
|
dcbc602d88 | ||
|
|
fcf032042b | ||
|
|
01f02360c4 | ||
|
|
57de7bf967 | ||
|
|
173f46a145 | ||
|
|
03f963340e | ||
|
|
37ef886503 | ||
|
|
6c1da79b4a | ||
|
|
6a17f40042 | ||
|
|
351e4ad28d | ||
|
|
983809714b | ||
|
|
ded86e1c3f | ||
|
|
b4bcc05162 | ||
|
|
e8d45f2f7b | ||
|
|
e93a2bbd3a | ||
|
|
e0b3999d0c | ||
|
|
77322e8644 | ||
|
|
6085b5a094 | ||
|
|
e0cc5a06b7 | ||
|
|
667c6177f4 | ||
|
|
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 | ||
|
|
8eb2bbd35d | ||
|
|
55db8f03b3 | ||
|
|
e9a845a6ea | ||
|
|
23e382bf7f | ||
|
|
438b20dae0 | ||
|
|
19f8b5291e | ||
|
|
c911143eb2 | ||
|
|
1a90af7800 | ||
|
|
92c9616826 | ||
|
|
2db44e53f7 | ||
|
|
68ede6ab25 | ||
|
|
d448c1e3a4 | ||
|
|
3d6b04e78a | ||
|
|
e1f5595afc | ||
|
|
3e46116a9b | ||
|
|
10b8995041 | ||
|
|
225b7ecfcc |
No files matched your search
@@ -34,7 +34,6 @@ go.work.sum
|
||||
|
||||
|
||||
bin
|
||||
dist
|
||||
*.db
|
||||
.DS_Store
|
||||
*/.DS_Store
|
||||
@@ -1,80 +1,229 @@
|
||||
# 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 без промежуточных файлов, с прогрессом,
|
||||
скоростью и отменой. «Переместить» удаляет исходник после успешной отправки. Правила для
|
||||
существующего: сообщить / заменить / оставить как есть.
|
||||
- **ИИ-ассистент** - круглая кнопка чата (на компьютере её и окно можно перетаскивать); каждый
|
||||
включает себе сам в профиле. Видит вкладки, активный терминал или папку и список ваших машин,
|
||||
отвечает потоком. Действия - кнопками: выполнить команду в терминале нужной машины, прочитать,
|
||||
показать или записать файл - всё с правами пользователя. Плашка «Агент» - делает шаги сам,
|
||||
спрашивая перед каждой командой и записью; «Думать» - сначала рассуждает (свёрнуто над
|
||||
ответом). Модели задаёт администратор в «Настройки › ИИ-ассистент»: их может быть несколько, у каждой своё
|
||||
название в чате (и короткое, до 12 символов, - для кнопки выбора), тип - Gradio (по умолчанию - прокси к Qwen Omni), OpenAI-совместимый API или Anthropic,
|
||||
адрес, модель и ключ (хранится на сервере, в браузер не уходит). Если моделей несколько, в чате рядом с
|
||||
плашками появляется выбор, первая - по умолчанию; выбор запоминается за пользователем. В «Аккаунт › Вид»
|
||||
можно показывать выбор всегда.
|
||||
Плашка «Поиск» даёт ассистенту искать в интернете; поисковик выбирается там же: DuckDuckGo
|
||||
(по умолчанию, без настройки), свой SearXNG (нужен `search.formats: [json]`), Brave Search API
|
||||
или Tavily (по ключу). Если выбранный не ответил, поиск идёт через DuckDuckGo.
|
||||
**Картинки**: если администратор задал API-ключ в «Настройки › ИИ-ассистент › Генерация изображений»
|
||||
([Vertex API](https://s2.x-grk.com/api/vertex/docs)), модели сами, по смыслу разговора и без плашки, рисуют картинки
|
||||
и правят фото - то, что пользователь прикрепил к сообщению, или последнюю созданную картинку
|
||||
(блоки ```` ```image ```` и ```` ```image edit ````, формат и качество - в первой строке; улучшение описания
|
||||
можно отключить: `noenhance`). Картинка показывается в чате, описание в карточке сворачивается.
|
||||
Название прикреплённого файла открывает его (или скачивает). Удаление чата стирает его содержимое, в том числе
|
||||
текст вложений, из файлов базы; картинки хранятся на сервисе Vertex и оттуда не удаляются.
|
||||
- **Сохранённые 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`, она вшивается в сервер и агент.
|
||||
|
||||
## Установка сервера одной командой
|
||||
На Linux-сервере (amd64 или arm64, systemd):
|
||||
```sh
|
||||
wget -qO- https://gitlab.com/itzredtea/lpra/-/raw/main/server-install.sh | sudo sh
|
||||
```
|
||||
Скрипт `server-install.sh`:
|
||||
- качает бинарник сервера под архитектуру машины из [последнего релиза](https://gitlab.com/itzredtea/lpra/-/releases/permalink/latest)
|
||||
и папку `dist/` (бинарники агентов) из ветки `main`, кладёт всё в `/opt/lpra`;
|
||||
- ставит службу systemd `lpra-server` (порт спросит, по умолчанию `8091`);
|
||||
- по желанию настраивает nginx: спросит домен, создаст сайт на порт 80 (при необходимости поставит nginx),
|
||||
затем `certbot --nginx` - если certbot с плагином nginx не установлен, этот шаг пропускается;
|
||||
- если включён ufw - предложит открыть 80, 443 и туннель `8446` tcp/udp;
|
||||
- запускает службу, перезагружает nginx и проверяет, что сервер отвечает.
|
||||
|
||||
Вопросы задаются в терминале, даже когда скрипт идёт через `| sudo sh`. Без терминала ответы берутся
|
||||
из переменных: `LPRA_PORT`, `LPRA_NGINX` (y/n), `LPRA_DOMAIN`, `LPRA_EMAIL`, `LPRA_CERTBOT` (y/n), `LPRA_UFW` (y/n):
|
||||
```sh
|
||||
wget -qO- https://gitlab.com/itzredtea/lpra/-/raw/main/server-install.sh | sudo LPRA_NGINX=y LPRA_DOMAIN=lpra.example.com sh
|
||||
```
|
||||
Повторный запуск той же командой - **обновление**: бинарник и `dist/` заменяются, база (`/opt/lpra/lpra.db`
|
||||
и её ключ `lpra.db.key`), настройки службы и nginx остаются. После установки откройте `/setup` - мастер
|
||||
создаст администратора.
|
||||
|
||||
## Запуск сервера
|
||||
```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` (в панели - http-lp) идёт через
|
||||
соединение агента с панелью: по WebSocket, а если он недоступен - по long-polling;
|
||||
`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" />
|
||||
<img width="1256" height="709" alt="image" src="docs/screenshots/1.png" />
|
||||
|
||||
<img width="1254" height="532" alt="image" src="https://github.com/user-attachments/assets/0207a7f0-2b59-40ee-abd1-d8673059cc24" />
|
||||
<img width="1254" height="532" alt="image" src="docs/screenshots/2.png" />
|
||||
|
||||
<img width="1255" height="526" alt="image" src="https://github.com/user-attachments/assets/4dcbe907-33f5-493e-a56f-01cd8bf0fea1" />
|
||||
<img width="1255" height="526" alt="image" src="docs/screenshots/3.png" />
|
||||
|
||||
<img width="1260" height="654" alt="image" src="https://github.com/user-attachments/assets/fdf59733-8e5d-46e3-b232-235ddfe1be73" />
|
||||
<img width="1260" height="654" alt="image" src="docs/screenshots/4.png" />
|
||||
+1
-1
@@ -1 +1 @@
|
||||
0.0.6
|
||||
0.2.4
|
||||
+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,25 +4,35 @@
|
||||
# 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:
|
||||
# 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 120s -> longer than the 25s long-poll hold
|
||||
# - proxy_read_timeout 300s -> longer than the 25s long-poll hold
|
||||
#
|
||||
# NOTE: proxy_pass MUST start with "http://". A bare "proxy_pass 127.0.0.1:8091;"
|
||||
# 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
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
|
||||
|
||||
# Agent binaries can be large; allow the download.
|
||||
client_max_body_size 64m;
|
||||
# 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 120m;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8091;
|
||||
@@ -32,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;
|
||||
@@ -39,7 +52,7 @@ server {
|
||||
# Pass uploads on as they arrive: the speed test through the browser
|
||||
# times them on the client.
|
||||
proxy_request_buffering off;
|
||||
proxy_read_timeout 120s;
|
||||
proxy_send_timeout 120s;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,144 @@
|
||||
# Перенос проекта с GitHub на GitLab с сохранением истории и дат
|
||||
|
||||
Инструкция для локального репозитория, когда GitHub недоступен (аккаунт заблокирован) и
|
||||
переносить приходится из своей копии. Так был перенесён `lpra`: 195 коммитов, 8 тегов,
|
||||
3 релиза - хэши и даты совпали.
|
||||
|
||||
## Что сохраняется, а что нет
|
||||
|
||||
| Что | Сохраняется? |
|
||||
|---|---|
|
||||
| Даты коммитов (автора и коммита) | **Да, точно.** Они хранятся внутри коммита; push той же истории не меняет ни одного хэша |
|
||||
| Ветки и теги | Да, если они есть локально (см. шаг 2) |
|
||||
| Дата создания проекта | **Нет.** На gitlab.com она всегда - день создания проекта, задним числом не ставится. Начало проекта видно по первому коммиту |
|
||||
| Релизы | Создаются заново, **с исходной датой** (`--released-at`); файлы релизов - заново собрать или взять свои копии |
|
||||
| Issues, PR, звёзды, Actions | Нет: при заблокированном GitHub их не выгрузить |
|
||||
|
||||
**Нельзя:** `rebase`, `squash`, `filter-branch`, `filter-repo`, `commit --amend` по старым
|
||||
коммитам, «Import from GitHub» с чужой копии - всё это переписывает коммиты и их даты.
|
||||
|
||||
## 0. Один раз: вход в GitLab
|
||||
|
||||
```sh
|
||||
brew install glab
|
||||
glab auth login --hostname gitlab.com --web --git-protocol https
|
||||
```
|
||||
|
||||
Вход через браузер даёт права и на push, и на релизы. Git берёт пароль у glab - для каждого
|
||||
репозитория это настраивается в шаге 4 (или один раз для всех:
|
||||
`git config --global credential.https://gitlab.com.helper '!glab auth git-credential'`).
|
||||
|
||||
SSH-ключ тоже подойдёт, но в GitLab у ключа должен быть **Usage type: Authentication & Signing**
|
||||
(с типом «Signing» по SSH не пускает).
|
||||
|
||||
## 1. Пустой проект в GitLab
|
||||
|
||||
https://gitlab.com/projects/new → **Create blank project** → имя, владелец `itzredtea`.
|
||||
**Снять галочку «Initialize repository with a README»** - иначе в проекте появится свой первый
|
||||
коммит, и историю придётся склеивать.
|
||||
|
||||
Или из терминала:
|
||||
|
||||
```sh
|
||||
glab repo create itzredtea/ИМЯ --public # или --private; README не создаётся
|
||||
```
|
||||
|
||||
## 2. Проверить, что всё есть локально
|
||||
|
||||
```sh
|
||||
cd ~/CodeRepos/ИМЯ
|
||||
git status # незакоммиченное закоммитить или убрать
|
||||
git branch -a # ветки: свои и remotes/origin/*
|
||||
git tag -l # теги
|
||||
```
|
||||
|
||||
- Ветки, которых нет локально, но есть `remotes/origin/X`, тоже переносятся (шаг 3).
|
||||
- **Теги, созданные на GitHub при публикации релиза, могут не быть локально** - если их не
|
||||
делали `git fetch --tags`. Такой тег создаётся заново на нужном коммите (шаг 5). Коммит
|
||||
ищется по дате публикации релиза: последний коммит перед ней
|
||||
(`git log --format='%h %cd %s' --date=iso --until='ДАТА'`).
|
||||
|
||||
Снимок до переноса - для сверки:
|
||||
|
||||
```sh
|
||||
git log --branches --remotes=origin --tags --format='%H %aI %cI' | sort > /tmp/before.txt
|
||||
git show-ref --tags | sort > /tmp/tags-before.txt
|
||||
```
|
||||
|
||||
## 3. Отправить историю
|
||||
|
||||
```sh
|
||||
git remote rename origin github # GitHub остаётся под своим именем
|
||||
git remote add origin https://gitlab.com/itzredtea/ИМЯ.git
|
||||
H='!glab auth git-credential'
|
||||
git -c credential.helper= -c "credential.helper=$H" push origin --all # все локальные ветки
|
||||
git -c credential.helper= -c "credential.helper=$H" push origin 'refs/remotes/github/*:refs/heads/*' # ветки, что были только на GitHub
|
||||
git -c credential.helper= -c "credential.helper=$H" push origin --tags
|
||||
```
|
||||
|
||||
Вторая команда push может сказать про `HEAD` - это не ветка, сообщение можно не смотреть.
|
||||
|
||||
## 4. Сверить и переключиться
|
||||
|
||||
Сверка - по свежей копии из GitLab:
|
||||
|
||||
```sh
|
||||
rm -rf /tmp/check && git clone -q https://gitlab.com/itzredtea/ИМЯ.git /tmp/check
|
||||
cd /tmp/check && git log --remotes=origin --tags --format='%H %aI %cI' | sort > /tmp/after.txt
|
||||
git show-ref --tags | sort > /tmp/tags-after.txt
|
||||
diff /tmp/before.txt /tmp/after.txt && echo "коммиты и даты совпали"
|
||||
diff /tmp/tags-before.txt /tmp/tags-after.txt && echo "теги совпали"
|
||||
```
|
||||
|
||||
Пустой `diff` - история перенесена один в один. Дальше работать с GitLab:
|
||||
|
||||
```sh
|
||||
cd ~/CodeRepos/ИМЯ
|
||||
git config --local credential.https://gitlab.com.helper '!glab auth git-credential'
|
||||
git fetch origin && git branch -u origin/main main
|
||||
```
|
||||
|
||||
## 5. Релизы с исходными датами
|
||||
|
||||
Дата - время публикации релиза на GitHub (UTC). Файлы - свои копии или сборка из коммита
|
||||
релиза во временной папке:
|
||||
|
||||
```sh
|
||||
git worktree add --detach /tmp/rel-1.2.0 1.2.0 # тег или хэш коммита релиза
|
||||
cd /tmp/rel-1.2.0 && ./build.sh # как проект собирается
|
||||
glab release create 1.2.0 bin/файл1 bin/файл2 \
|
||||
-R itzredtea/ИМЯ --name "Release 1.2.0" \
|
||||
--released-at "2026-10-15T16:34:53Z" \
|
||||
--notes "Перенесено с GitHub, дата публикации исходная."
|
||||
# тега нет (его делал GitHub) - добавить: --ref <полный хэш коммита>
|
||||
git worktree remove /tmp/rel-1.2.0
|
||||
```
|
||||
|
||||
Создавать по порядку, от старых к новым. Последним GitLab считает релиз с самой поздней
|
||||
датой. Ссылки на файлы:
|
||||
|
||||
- конкретный релиз: `https://gitlab.com/itzredtea/ИМЯ/-/releases/ТЕГ/downloads/ФАЙЛ`
|
||||
- последний: `https://gitlab.com/itzredtea/ИМЯ/-/releases/permalink/latest/downloads/ФАЙЛ`
|
||||
|
||||
## 6. Ссылки в самом проекте
|
||||
|
||||
Найти и заменить всё, что смотрит на GitHub:
|
||||
|
||||
```sh
|
||||
grep -rn "github.com\|githubusercontent" --exclude-dir=.git .
|
||||
```
|
||||
|
||||
| GitHub | GitLab |
|
||||
|---|---|
|
||||
| `https://raw.githubusercontent.com/USER/ИМЯ/main/ПУТЬ` | `https://gitlab.com/itzredtea/ИМЯ/-/raw/main/ПУТЬ` |
|
||||
| `https://github.com/USER/ИМЯ/releases/latest/download/ФАЙЛ` | `https://gitlab.com/itzredtea/ИМЯ/-/releases/permalink/latest/downloads/ФАЙЛ` |
|
||||
| `https://api.github.com/repos/USER/ИМЯ/releases/latest` (`tag_name`) | `https://gitlab.com/api/v4/projects/itzredtea%2FИМЯ/releases/permalink/latest` (`tag_name`) |
|
||||
| `https://api.github.com/repos/USER/ИМЯ/contents/ПАПКА?ref=main` | `https://gitlab.com/api/v4/projects/itzredtea%2FИМЯ/repository/tree?path=ПАПКА&ref=main` (поле `path`) |
|
||||
| картинки `github.com/user-attachments/...` | скачать, пока открываются, в `docs/` и ссылаться на файлы |
|
||||
|
||||
Путь Go-модуля (`module github.com/...` в `go.mod`) можно не трогать: это только имя.
|
||||
|
||||
## 7. После переноса
|
||||
|
||||
- Описание проекта в GitLab: «Перенесён с GitHub, начат ДД.ММ.ГГГГ» - раз дата создания новая.
|
||||
- Токены, которые светились в чатах и логах, - отозвать и выпустить новые.
|
||||
@@ -0,0 +1,73 @@
|
||||
# План: управление потоком в прямом туннеле (tcp-проброс)
|
||||
|
||||
Статус: **отложено** - сделать, если на практике упрёмся. Записано 2026-10-16.
|
||||
|
||||
## Что сейчас
|
||||
|
||||
Прямой туннель (`:8446`, режим проброса `tcp`) - одно TCP-соединение на агента, по нему
|
||||
мультиплексируются все проброшенные соединения этого агента кадрами
|
||||
`type(1) | stream(4) | length(4) | payload` (`internal/tunnel/frame.go`).
|
||||
|
||||
Ёмкости хватает с запасом: на агента - одно соединение и одна горутина чтения, на проброшенное
|
||||
соединение - горутина и буфер 32 КБ; шифрования в туннеле нет. 20 агентов и тысячи соединений -
|
||||
десятки МБ памяти и почти ноль CPU. Потолок - сеть хоста (1 Гбит/с), через которую весь трафик
|
||||
идёт дважды (пользователь ↔ хост ↔ агент).
|
||||
|
||||
## Проблема: блокировка всего туннеля агента одним соединением
|
||||
|
||||
Управления потоком (окон, как в HTTP/2 или yamux) нет, поэтому внутри одного агента медленное
|
||||
соединение тормозит остальные:
|
||||
|
||||
- **Хост** (`internal/server/tunnelserver.go`, `agentTunnel.readLoop`): кадр `TypeData` пишется
|
||||
пользователю синхронно, `c.Write(f.Payload)`, без дедлайна. Клиент, который медленно читает или
|
||||
завис, останавливает чтение туннеля - встают все пробросы этого агента, пока клиент не отвалится.
|
||||
- **Агент** (`internal/agent/tunnel.go`, `tunClient.readLoop`): данные кладутся в канал потока
|
||||
`in` (256 кусков, до ~8 МБ). Если локальный сервис не читает и канал полон, `readLoop`
|
||||
блокируется на `s.in <- f.Payload` - встаёт весь туннель агента.
|
||||
|
||||
Между агентами влияния нет: у каждого свой `readLoop`.
|
||||
|
||||
Когда проявится: через одного агента одновременно большая отдача медленному клиенту и что-то
|
||||
интерактивное (SSH, веб) - интерактивное подвисает. Худший случай - зависший клиент держит
|
||||
туннель агента до разрыва TCP.
|
||||
|
||||
## Предлагаемое решение
|
||||
|
||||
1. **Своя очередь записи у каждого потока** - на хосте и на агенте. `readLoop` только кладёт
|
||||
кадр в очередь потока и никогда не пишет в сокет сам; пишет отдельная горутина потока.
|
||||
2. **Окно (кредиты) на поток.** Новый тип кадра `TypeWindow` (stream, uint32 байт): получатель
|
||||
сообщает, сколько ещё байт готов принять. Отправитель не шлёт `TypeData` сверх окна.
|
||||
Начальное окно - 256 КБ; получатель возвращает кредит по мере записи в сокет (например, когда
|
||||
записана половина окна). Так очередь потока ограничена окном, а медленный поток тормозит
|
||||
только себя.
|
||||
3. **Дедлайны записи.** Запись в пользовательский или локальный сокет - с `SetWriteDeadline`
|
||||
(например, 60 с без прогресса). Не успел - поток закрывается (`TypeClose`), туннель живёт.
|
||||
4. **Справедливость отправки.** Отправка в туннель уже сериализована (`wmu`); кадры данных
|
||||
ограничить 32 КБ (сейчас так и есть), чтобы большой поток не занимал канал надолго.
|
||||
|
||||
## Совместимость со старыми агентами
|
||||
|
||||
Рукопожатие `LPRA-TUN1` не меняем: старый агент отвергает другое приветствие, а старый сервер -
|
||||
ответ не из двух полей. Вместо этого:
|
||||
|
||||
- сервер знает версию агента (из БД, `versionAtLeast`) и, если агент новый, сразу после
|
||||
рукопожатия шлёт кадр `TypeHello` (флаги: `flowControl`);
|
||||
- агент включает окна, только получив `TypeHello`. Старый сервер его не шлёт - новый агент
|
||||
работает с ним по-старому. Старому агенту сервер `TypeHello` не шлёт и окна не включает (а
|
||||
пришедший неизвестный кадр старый агент и так пропустил бы: в его `readLoop` нет `default`).
|
||||
|
||||
Новые типы кадров: `TypeHello = 5`, `TypeWindow = 6`.
|
||||
|
||||
## Тесты
|
||||
|
||||
- Медленный читатель на одном потоке не задерживает другой поток того же агента (замер задержки
|
||||
эха на втором потоке при забитом первом).
|
||||
- Зависший клиент закрывается по дедлайну, туннель и остальные потоки живы.
|
||||
- Новый сервер + старый агент и старый сервер + новый агент - проброс работает, как раньше.
|
||||
- Пропускная способность одного потока не падает заметно (окно 256 КБ на RTT до 50 мс даёт
|
||||
~40 Мбит/с на поток; если мало - окно больше или автоподстройка).
|
||||
|
||||
## Объём
|
||||
|
||||
Сервер: `tunnelserver.go` (~150 строк), агент: `tunnel.go` (~150 строк), `frame.go` (типы), тесты.
|
||||
Нужна новая версия агента; панель после обновления помечает старых как устаревших.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 143 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 97 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 156 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 226 KiB |
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=
|
||||
+173
-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,16 +44,23 @@ 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.
|
||||
type Agent struct {
|
||||
cfg Config
|
||||
http *http.Client
|
||||
stream *http.Client
|
||||
state state
|
||||
stateMu sync.Mutex
|
||||
hosts hostState
|
||||
pollCount int
|
||||
|
||||
// Direct tunnel currently running (see setTunnelAddr).
|
||||
transport atomic.Value // string: auto, ws or lp, as the server says
|
||||
link atomic.Pointer[wsLink]
|
||||
tunMu sync.Mutex
|
||||
tunAddr string
|
||||
tunCancel context.CancelFunc
|
||||
@@ -81,6 +90,8 @@ func New(cfg Config) *Agent {
|
||||
cfg: cfg,
|
||||
// Timeout must exceed the server's long-poll hold (25s) plus slack.
|
||||
http: &http.Client{Timeout: 60 * time.Second, Transport: tr},
|
||||
// File streams take as long as the file needs.
|
||||
stream: &http.Client{Transport: tr},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,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
|
||||
@@ -104,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{
|
||||
@@ -133,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)
|
||||
@@ -154,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.
|
||||
@@ -180,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
|
||||
@@ -197,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
|
||||
}
|
||||
@@ -219,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
|
||||
}
|
||||
@@ -274,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
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||
)
|
||||
|
||||
// Files go to and from the browser in pieces, each a request of its own
|
||||
// through the server: small enough for any proxy in front, and sent again
|
||||
// alone when the link drops it. A few go at once, so a good link is kept as
|
||||
// busy as by one long stream.
|
||||
|
||||
const (
|
||||
// xferWorkers is how many pieces are in flight at once.
|
||||
xferWorkers = 4
|
||||
// xferPieceTimeout bounds one piece's request: a link that slow, or
|
||||
// stuck, gets the piece sent again.
|
||||
xferPieceTimeout = 5 * time.Minute
|
||||
// xferTries bounds how often a piece is tried over a failing link.
|
||||
xferTries = 30
|
||||
)
|
||||
|
||||
// errXferGone is the server saying the transfer is over: the browser left
|
||||
// or gave up.
|
||||
type errXferGone string
|
||||
|
||||
func (e errXferGone) Error() string { return string(e) }
|
||||
|
||||
func (a *Agent) xferURL(id string, i int) string {
|
||||
return a.host() + "/api/agent/fs/xfer/" + id + "/" + strconv.Itoa(i)
|
||||
}
|
||||
|
||||
// xferRequest makes one request for a piece, trying again over a failing link.
|
||||
// A 503 is the server waiting on the other side: asked again at once.
|
||||
func (a *Agent) xferRequest(ctx context.Context, newReq func(context.Context) (*http.Request, error), keep func(*http.Response) error) error {
|
||||
fails := 0
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
rctx, cancel := context.WithTimeout(ctx, xferPieceTimeout)
|
||||
req, err := newReq(rctx)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return err
|
||||
}
|
||||
a.authHeaders(req)
|
||||
resp, err := a.stream.Do(req)
|
||||
if err == nil {
|
||||
switch {
|
||||
case resp.StatusCode == http.StatusGone:
|
||||
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
resp.Body.Close()
|
||||
cancel()
|
||||
return errXferGone(strings.TrimSpace(string(msg)))
|
||||
case resp.StatusCode == http.StatusServiceUnavailable:
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
cancel()
|
||||
continue
|
||||
case resp.StatusCode < 300:
|
||||
err = keep(resp)
|
||||
resp.Body.Close()
|
||||
cancel()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
default:
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
err = errStatus(resp.StatusCode)
|
||||
}
|
||||
}
|
||||
cancel()
|
||||
if fails++; fails >= xferTries {
|
||||
return fmt.Errorf("сеть не держит передачу: %w", err)
|
||||
}
|
||||
select {
|
||||
case <-time.After(min(time.Duration(fails)*time.Second, 10*time.Second)):
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pieces runs fn for every piece, xferWorkers at a time, until one fails.
|
||||
func pieces(ctx context.Context, n int, fn func(ctx context.Context, i int) error) error {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
var (
|
||||
mu sync.Mutex
|
||||
next int
|
||||
first error
|
||||
wg sync.WaitGroup
|
||||
)
|
||||
for w := 0; w < min(xferWorkers, n); w++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
mu.Lock()
|
||||
if first != nil || next >= n {
|
||||
mu.Unlock()
|
||||
return
|
||||
}
|
||||
i := next
|
||||
next++
|
||||
mu.Unlock()
|
||||
if err := fn(ctx, i); err != nil {
|
||||
mu.Lock()
|
||||
if first == nil {
|
||||
first = err
|
||||
cancel()
|
||||
}
|
||||
mu.Unlock()
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
return first
|
||||
}
|
||||
|
||||
func pieceCount(size int64, chunk int) int {
|
||||
if size <= 0 {
|
||||
return 1
|
||||
}
|
||||
return int((size + int64(chunk) - 1) / int64(chunk))
|
||||
}
|
||||
|
||||
// doFSPut writes the file the browser is uploading. It lands in a temporary
|
||||
// file next to the target first, so a broken upload leaves the old file as it
|
||||
// was.
|
||||
func (a *Agent) doFSPut(ctx context.Context, env proto.TaskEnvelope) proto.TaskResult {
|
||||
var args proto.FSXferArgs
|
||||
if err := json.Unmarshal(env.Args, &args); err != nil {
|
||||
return proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
if args.Size < 0 || args.Size > proto.FSFileCap || args.Chunk <= 0 {
|
||||
return proto.TaskResult{Error: "файл больше 512 МБ"}
|
||||
}
|
||||
dir, base := filepath.Split(args.Path)
|
||||
if dir == "" {
|
||||
dir = "."
|
||||
}
|
||||
if info, err := os.Stat(args.Path); err == nil && info.IsDir() {
|
||||
return proto.TaskResult{Error: "это каталог"}
|
||||
}
|
||||
tmp, err := os.CreateTemp(dir, "."+base+".lpra-*")
|
||||
if err != nil {
|
||||
return proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
done := false
|
||||
defer func() {
|
||||
if !done {
|
||||
tmp.Close()
|
||||
os.Remove(tmp.Name())
|
||||
}
|
||||
}()
|
||||
|
||||
// Pieces written so far, to tell the server which it can let go.
|
||||
n := pieceCount(args.Size, args.Chunk)
|
||||
var (
|
||||
mu sync.Mutex
|
||||
got = make([]bool, n)
|
||||
contig int
|
||||
)
|
||||
err = pieces(ctx, n, func(ctx context.Context, i int) error {
|
||||
want := min(int64(args.Chunk), args.Size-int64(i)*int64(args.Chunk))
|
||||
mu.Lock()
|
||||
ack := contig
|
||||
mu.Unlock()
|
||||
var data []byte
|
||||
err := a.xferRequest(ctx, func(ctx context.Context) (*http.Request, error) {
|
||||
return http.NewRequestWithContext(ctx, http.MethodGet, a.xferURL(args.Xfer, i)+"?ack="+strconv.Itoa(ack), nil)
|
||||
}, func(resp *http.Response) error {
|
||||
b, err := io.ReadAll(io.LimitReader(resp.Body, int64(args.Chunk)+1))
|
||||
if err == nil && int64(len(b)) != want {
|
||||
err = errors.New("кусок не той длины")
|
||||
}
|
||||
data = b
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tmp.WriteAt(data, int64(i)*int64(args.Chunk)); err != nil {
|
||||
return err
|
||||
}
|
||||
mu.Lock()
|
||||
got[i] = true
|
||||
for contig < n && got[contig] {
|
||||
contig++
|
||||
}
|
||||
mu.Unlock()
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
var gone errXferGone
|
||||
if errors.As(err, &gone) {
|
||||
return proto.TaskResult{Error: "загрузка прервана: " + string(gone)}
|
||||
}
|
||||
return proto.TaskResult{Error: "загрузка прервана: " + err.Error()}
|
||||
}
|
||||
if err := tmp.Truncate(args.Size); err != nil {
|
||||
return proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
// A file that is being replaced keeps its mode and owner.
|
||||
mode := os.FileMode(0o644)
|
||||
if info, err := os.Stat(args.Path); err == nil {
|
||||
mode = info.Mode().Perm()
|
||||
if st, ok := info.Sys().(*syscall.Stat_t); ok {
|
||||
_ = os.Chown(tmp.Name(), int(st.Uid), int(st.Gid))
|
||||
}
|
||||
}
|
||||
if err := os.Chmod(tmp.Name(), mode); err != nil {
|
||||
return proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
if err := os.Rename(tmp.Name(), args.Path); err != nil {
|
||||
return proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
done = true
|
||||
data, _ := json.Marshal(map[string]any{"path": args.Path, "size": args.Size})
|
||||
return proto.TaskResult{OK: true, Data: data}
|
||||
}
|
||||
|
||||
// doFSSend sends a file to the browser that asked for it.
|
||||
func (a *Agent) doFSSend(ctx context.Context, env proto.TaskEnvelope) proto.TaskResult {
|
||||
var args proto.FSXferArgs
|
||||
if err := json.Unmarshal(env.Args, &args); err != nil {
|
||||
return proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
if args.Chunk <= 0 {
|
||||
return proto.TaskResult{Error: "не задан размер куска"}
|
||||
}
|
||||
f, err := os.Open(args.Path)
|
||||
if err != nil {
|
||||
return proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
defer f.Close()
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
if info.IsDir() {
|
||||
return proto.TaskResult{Error: "это каталог"}
|
||||
}
|
||||
size := info.Size()
|
||||
if size > proto.FSFileCap {
|
||||
return proto.TaskResult{Error: "файл больше 512 МБ"}
|
||||
}
|
||||
sizeHdr := strconv.FormatInt(size, 10)
|
||||
err = pieces(ctx, pieceCount(size, args.Chunk), func(ctx context.Context, i int) error {
|
||||
off := int64(i) * int64(args.Chunk)
|
||||
buf := make([]byte, min(int64(args.Chunk), size-off))
|
||||
if _, err := f.ReadAt(buf, off); err != nil && err != io.EOF {
|
||||
return err
|
||||
}
|
||||
return a.xferRequest(ctx, func(ctx context.Context) (*http.Request, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.xferURL(args.Xfer, i), bytes.NewReader(buf))
|
||||
if err == nil {
|
||||
req.Header.Set("X-Size", sizeHdr)
|
||||
req.Header.Set("Content-Type", "application/octet-stream")
|
||||
}
|
||||
return req, err
|
||||
}, func(*http.Response) error { return nil })
|
||||
})
|
||||
if err != nil {
|
||||
return proto.TaskResult{Error: "передача прервана: " + err.Error()}
|
||||
}
|
||||
data, _ := json.Marshal(map[string]any{"path": args.Path, "size": size})
|
||||
return proto.TaskResult{OK: true, Data: data}
|
||||
}
|
||||
|
||||
// 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,12 +26,15 @@ 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)
|
||||
}
|
||||
|
||||
// runForward dials 127.0.0.1:port on the client and bridges it to the session,
|
||||
// giving the host a reverse TCP tunnel over the same long-poll transport.
|
||||
// giving the host a reverse TCP tunnel over the agent's link to the server:
|
||||
// the WebSocket, or the long polls without one.
|
||||
func (a *Agent) runForward(parent context.Context, sid string, port int) {
|
||||
d := net.Dialer{Timeout: 10 * time.Second}
|
||||
conn, err := d.DialContext(parent, "tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port)))
|
||||
@@ -54,6 +57,15 @@ func (a *Agent) bridgeStream(parent context.Context, sid string, rw io.ReadWrite
|
||||
ctx, cancel := context.WithCancel(parent)
|
||||
defer cancel()
|
||||
defer rw.Close()
|
||||
defer func() {
|
||||
if l := a.link.Load(); l != nil {
|
||||
l.unsubscribe(sid)
|
||||
}
|
||||
}()
|
||||
// A session closed from the panel (a tab reloaded, a window shut) ends
|
||||
// the stream at once, not on its next output: an idle shell would live
|
||||
// on until the agent stopped.
|
||||
go func() { <-ctx.Done(); rw.Close() }()
|
||||
|
||||
// Downstream: pull input frames and write them to the stream.
|
||||
go func() {
|
||||
@@ -81,10 +93,26 @@ func (a *Agent) bridgeStream(parent context.Context, sid string, rw io.ReadWrite
|
||||
}
|
||||
}
|
||||
|
||||
// pollIn performs one downstream long-poll and applies the frames.
|
||||
// pollIn takes the next input - pushed on the WebSocket, else by a 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 +129,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 +154,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)
|
||||
}
|
||||
}
|
||||
+28
-2
@@ -22,6 +22,20 @@ func (a *Agent) runTask(ctx context.Context, taskID string) {
|
||||
res = a.doUpdate()
|
||||
case proto.TaskSysSpeed:
|
||||
res = a.doSpeed(ctx, env)
|
||||
case proto.TaskFSPut:
|
||||
res = a.doFSPut(ctx, env)
|
||||
case proto.TaskFSSend:
|
||||
res = a.doFSSend(ctx, env)
|
||||
case proto.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)
|
||||
}
|
||||
@@ -30,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 {
|
||||
@@ -46,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)
|
||||
@@ -99,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
|
||||
}
|
||||
@@ -4,6 +4,11 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/creack/pty"
|
||||
)
|
||||
@@ -12,16 +17,52 @@ import (
|
||||
type PTY struct {
|
||||
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() {
|
||||
cmd = exec.Command(defaultShell(), "-l")
|
||||
// A login shell does not change directory by itself: it would start
|
||||
// where the service runs, "/". Start it at home, and say where home
|
||||
// is, as a root service often has no $HOME.
|
||||
if home := homeDir(""); isDir(home) {
|
||||
cmd.Dir = home
|
||||
env = append(env, "HOME="+home)
|
||||
}
|
||||
if dir != "" {
|
||||
cmd.Dir = dir
|
||||
}
|
||||
} else {
|
||||
if !validUserName(user) {
|
||||
return nil, fmt.Errorf("недопустимое имя пользователя %q", user)
|
||||
@@ -37,13 +78,31 @@ 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")
|
||||
}
|
||||
cmd.Env = append(os.Environ(), "TERM=xterm-256color")
|
||||
// su -l goes to the user's home; starting there too keeps the shell
|
||||
// out of "/" should it not.
|
||||
if home := homeDir(user); isDir(home) {
|
||||
cmd.Dir = home
|
||||
}
|
||||
}
|
||||
cmd.Env = env
|
||||
f, err := pty.Start(cmd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &PTY{cmd: cmd, f: f}, nil
|
||||
p := &PTY{cmd: cmd, f: f, exited: make(chan struct{})}
|
||||
go func() { _ = cmd.Wait(); close(p.exited) }()
|
||||
openPTYs.Store(p, struct{}{})
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func isDir(p string) bool {
|
||||
info, err := os.Stat(p)
|
||||
return err == nil && info.IsDir()
|
||||
}
|
||||
|
||||
// Read reads terminal output.
|
||||
@@ -60,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()
|
||||
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
|
||||
}
|
||||
_ = p.cmd.Wait()
|
||||
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
|
||||
|
||||
+117
-24
@@ -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() {
|
||||
// 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 {
|
||||
_ = exec.Command(path, "restart", "lpagent").Start()
|
||||
args := []string{"--no-block", "restart", "lpagent"}
|
||||
cmd := exec.Command(path, args...)
|
||||
if sudo != "" {
|
||||
cmd = exec.Command(sudo, append([]string{"-n", path}, args...)...)
|
||||
}
|
||||
if cmd.Run() == nil {
|
||||
return
|
||||
}
|
||||
// No systemd (e.g. launchd/manual run): exit so the supervisor restarts us.
|
||||
}
|
||||
// systemd brings a service that stopped back up (Restart=always).
|
||||
os.Exit(0)
|
||||
}
|
||||
CloseTerminals()
|
||||
if err := syscall.Exec(exe, os.Args, os.Environ()); err != nil {
|
||||
log.Printf("update: restart: %v", err)
|
||||
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})
|
||||
}
|
||||
}
|
||||
}
|
||||
+213
-5
@@ -1,15 +1,17 @@
|
||||
// Package proto describes the wire messages shared by the server and the agent.
|
||||
//
|
||||
// All agent<->server traffic is plain JSON over HTTP(S) long-polling so it
|
||||
// survives NAT and restrictive egress firewalls: the agent only ever makes
|
||||
// outbound requests.
|
||||
// All agent<->server traffic is plain JSON, on a WebSocket the agent opens or,
|
||||
// where that does not get through, over HTTP(S) long polls, so it survives
|
||||
// NAT and restrictive egress firewalls: the agent only ever connects out.
|
||||
package proto
|
||||
|
||||
import "strings"
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// Control poll message types (server -> agent, returned from /api/agent/poll).
|
||||
const (
|
||||
// CtrlIdle is returned when the long-poll times out with nothing to do.
|
||||
// CtrlIdle is returned when the control poll times out with nothing to do.
|
||||
CtrlIdle = "idle"
|
||||
// CtrlOpenTerminal tells the agent to spawn a PTY for the session id.
|
||||
CtrlOpenTerminal = "open_terminal"
|
||||
@@ -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"`
|
||||
@@ -36,13 +56,53 @@ type EnrollResponse struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
// ControlMessage is the body returned by the control long-poll.
|
||||
// ControlMessage is a command for the agent: on the WebSocket, or the body of
|
||||
// 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)
|
||||
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 +130,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,18 +157,62 @@ const (
|
||||
TaskFSZip = "fs.zip" // zip selected paths (native archive/zip)
|
||||
TaskFSMove = "fs.move" // move/rename
|
||||
TaskFSDownload = "fs.download" // read a whole file (larger cap) for browser download
|
||||
TaskFSStat = "fs.stat" // a file's properties: type, size, mode, owner
|
||||
TaskFSChmod = "fs.chmod" // change mode and owner, of a folder's contents too
|
||||
TaskFSCopy = "fs.copy" // copy or move, with a rule for what is already there
|
||||
TaskFSPut = "fs.put" // write a file the browser sends in pieces
|
||||
TaskFSSend = "fs.send" // send a file to the browser in pieces
|
||||
|
||||
TaskSysUpdate = "sys.update" // download the latest agent binary and restart
|
||||
TaskSysUsers = "sys.users" // report the agent's user and the login-capable users
|
||||
TaskSysUserAdd = "sys.useradd" // create a new OS user
|
||||
TaskSysStat = "sys.stat" // CPU load, RAM and disk usage right now
|
||||
TaskSysSpeed = "sys.speed" // measure the link to the server (ping, down, up)
|
||||
TaskDBTables = "db.tables" // an SQLite database's tables and views
|
||||
TaskDBRows = "db.rows" // a page of a table's rows, with each row's key
|
||||
TaskDBSet = "db.set" // set one value of one row
|
||||
TaskDBAdd = "db.add" // add a row
|
||||
TaskDBDel = "db.del" // delete rows by their keys
|
||||
TaskDBSQL = "db.sql" // run a statement typed in the panel
|
||||
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
|
||||
// file be edited only when it fits, so that a save never cuts it short.
|
||||
const FSReadCap = 5 * 1024 * 1024
|
||||
|
||||
// FSFileCap bounds a file sent either way in pieces (TaskFSPut, TaskFSSend).
|
||||
const FSFileCap = 512 << 20
|
||||
|
||||
// FSXferArgs are the arguments of TaskFSPut and TaskFSSend: the file, and the
|
||||
// transfer on the server whose pieces carry it - GET them for a put, POST them
|
||||
// for a send, Chunk bytes each. Size is a put's length; -1 for a send.
|
||||
type FSXferArgs struct {
|
||||
Path string `json:"path"`
|
||||
Xfer string `json:"xfer"`
|
||||
Size int64 `json:"size"`
|
||||
Chunk int `json:"chunk"`
|
||||
// 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.
|
||||
type TaskEnvelope struct {
|
||||
Type string `json:"type"`
|
||||
@@ -131,6 +247,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"`
|
||||
@@ -138,6 +295,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.
|
||||
@@ -200,3 +359,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,435 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"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))
|
||||
}
|
||||
|
||||
// Qwen Omni's files go where its inputs are (audio before image there), and
|
||||
// a video as Gradio 5 takes one: {video, subtitles}.
|
||||
func TestAIGradioMediaSlots(t *testing.T) {
|
||||
var joined map[string]any
|
||||
n := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/config":
|
||||
fmt.Fprint(w, `{"components":[{"id":20,"type":"textbox"},{"id":16,"type":"audio"},{"id":17,"type":"image"},{"id":18,"type":"video"},`+
|
||||
`{"id":13,"type":"chatbot"},{"id":7,"type":"textbox"},{"id":8,"type":"slider"},{"id":9,"type":"slider"},{"id":10,"type":"slider"}],`+
|
||||
`"dependencies":[{"id":0,"api_name":"chat_predict","targets":[[21,"click"]],"inputs":[20,16,17,18,13,7,8,9,10]}]}`)
|
||||
case "/gradio_api/upload":
|
||||
n++
|
||||
fmt.Fprintf(w, `["/tmp/f%d"]`, n)
|
||||
case "/gradio_api/queue/join":
|
||||
_ = json.NewDecoder(r.Body).Decode(&joined)
|
||||
fmt.Fprint(w, `{"event_id":"e"}`)
|
||||
case "/gradio_api/queue/data":
|
||||
fmt.Fprint(w, "data: {\"msg\":\"process_completed\",\"success\":true,\"output\":{\"data\":[{},{},{},{},[{\"role\":\"assistant\",\"content\":\"ok\"}],{},{}]}}\n\n")
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
enc := base64.StdEncoding.EncodeToString([]byte("x"))
|
||||
msgs := []aiMsg{{Role: "user", Content: "что тут", Attachments: []aiAttachment{
|
||||
{Name: "a.png", MIME: "image/png", Data: enc},
|
||||
{Name: "b.mp3", MIME: "audio/mpeg", Data: enc},
|
||||
{Name: "c.mp4", MIME: "video/mp4", Data: enc},
|
||||
}}}
|
||||
if err := aiStream(context.Background(), aiConfig{Type: "gradio", URL: srv.URL}, "sys", msgs, func(string) {}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d := joined["data"].([]any)
|
||||
path := func(v any) string { m, _ := v.(map[string]any); p, _ := m["path"].(string); return p }
|
||||
video, _ := d[3].(map[string]any)
|
||||
if path(d[1]) != "/tmp/f2" || path(d[2]) != "/tmp/f1" || path(video["video"]) != "/tmp/f3" || video["subtitles"] != nil || d[5] != "sys" {
|
||||
t.Fatalf("data: %v", d)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The model set before there could be several is the first of the list.
|
||||
func TestAIModelsFromLegacy(t *testing.T) {
|
||||
s, _ := newTestServer(t)
|
||||
_ = s.db.SetConfig(cfgAIType, "openai")
|
||||
_ = s.db.SetConfig(cfgAIURL, "https://api.example/v1/")
|
||||
_ = s.db.SetConfig(cfgAIModel, "gpt-x")
|
||||
_ = s.db.SetConfig(cfgAIKey, "k1")
|
||||
ms := s.aiModels()
|
||||
if len(ms) != 1 || ms[0].Type != "openai" || ms[0].URL != "https://api.example/v1" || ms[0].Model != "gpt-x" || ms[0].Key != "k1" || ms[0].ID == "" {
|
||||
t.Fatalf("models: %+v", ms)
|
||||
}
|
||||
if got := s.aiModel("nope"); got.ID != ms[0].ID {
|
||||
t.Fatalf("an unknown id is not the first model: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// postAIModels sends the settings form with the given models.
|
||||
func postAIModels(t *testing.T, s *Server, c *http.Cookie, models [][7]string) {
|
||||
t.Helper()
|
||||
f := url.Values{"csrf": {"tok"}}
|
||||
for _, m := range models {
|
||||
f.Add("m_id", m[0])
|
||||
f.Add("m_name", m[1])
|
||||
f.Add("m_type", m[2])
|
||||
f.Add("m_url", m[3])
|
||||
f.Add("m_model", m[4])
|
||||
f.Add("m_key", m[5])
|
||||
f.Add("m_short", m[6])
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/settings/ai", strings.NewReader(f.Encode()))
|
||||
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 w.Code != http.StatusSeeOther {
|
||||
t.Fatalf("save: %d %s", w.Code, w.Body)
|
||||
}
|
||||
}
|
||||
|
||||
// Настройки keep several models with their names; the chat learns the
|
||||
// names and ids only, never a key.
|
||||
func TestAIModelsSaveAndList(t *testing.T) {
|
||||
s, c := newTestServer(t)
|
||||
postAIModels(t, s, c, [][7]string{
|
||||
{"", "Qwen", "gradio", "", "", "", ""},
|
||||
{"", "Claude", "anthropic", "https://api.anthropic.com", "claude-x", "secret-key", "Сонет 4.5 длинно"},
|
||||
{"", "", "", "", "", "", ""}, // an empty card is dropped
|
||||
})
|
||||
ms := s.aiModels()
|
||||
if len(ms) != 2 || ms[0].Name != "Qwen" || ms[1].Name != "Claude" || ms[1].Key != "secret-key" || ms[0].ID == "" || ms[0].ID == ms[1].ID || ms[1].Short != "Сонет 4.5 дл" {
|
||||
t.Fatalf("models: %+v", ms)
|
||||
}
|
||||
body := get(t, s, c, "/api/panel/ai").Body.String()
|
||||
if strings.Contains(body, "secret-key") || !strings.Contains(body, `"name":"Claude"`) || !strings.Contains(body, `"short":"Сонет 4.5 дл"`) || !strings.Contains(body, ms[1].ID) {
|
||||
t.Fatalf("state: %s", body)
|
||||
}
|
||||
// Saved again with its id, a model keeps it.
|
||||
postAIModels(t, s, c, [][7]string{{ms[1].ID, "Claude 2", "anthropic", "https://api.anthropic.com", "claude-x", "secret-key", ""}})
|
||||
if got := s.aiModels(); len(got) != 1 || got[0].ID != ms[1].ID || got[0].Name != "Claude 2" {
|
||||
t.Fatalf("after resave: %+v", got)
|
||||
}
|
||||
if !strings.Contains(get(t, s, c, "/settings?frag=1").Body.String(), `value="Claude 2"`) {
|
||||
t.Fatal("the settings page does not show the model's name")
|
||||
}
|
||||
}
|
||||
|
||||
// The chat asks the model it names.
|
||||
func TestAIChatUsesChosenModel(t *testing.T) {
|
||||
s, c := newTestServer(t)
|
||||
srv := func(word string) *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprintf(w, "data: {\"choices\":[{\"delta\":{\"content\":\"%s\"}}]}\n\ndata: [DONE]\n\n", word)
|
||||
}))
|
||||
}
|
||||
one, two := srv("first"), srv("second")
|
||||
defer one.Close()
|
||||
defer two.Close()
|
||||
postAIModels(t, s, c, [][7]string{
|
||||
{"", "One", "openai", one.URL, "m", "", ""},
|
||||
{"", "Two", "openai", two.URL, "m", "", ""},
|
||||
})
|
||||
ms := s.aiModels()
|
||||
ask := func(model string) string {
|
||||
b, _ := json.Marshal(map[string]any{"message": "hi", "context": "x", "model": model})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/panel/ai/chat", strings.NewReader(string(b)))
|
||||
req.AddCookie(c)
|
||||
w := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(w, req)
|
||||
return w.Body.String()
|
||||
}
|
||||
if got := ask(ms[1].ID); !strings.Contains(got, "second") {
|
||||
t.Fatalf("model Two: %q", got)
|
||||
}
|
||||
if got := ask(""); !strings.Contains(got, "first") {
|
||||
t.Fatalf("no model named - the first: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The assistant is on unless the user turned it off - for the web search
|
||||
// and page reading as for the chat (a user who never touched the switch
|
||||
// has no setting at all).
|
||||
func TestAISearchAndFetchOnByDefault(t *testing.T) {
|
||||
s, c := newTestServer(t)
|
||||
sx := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprint(w, `{"results":[{"title":"Intel","url":"https://example.com/intel","content":"процессоры"}]}`)
|
||||
}))
|
||||
defer sx.Close()
|
||||
_ = s.db.SetConfig(cfgSearchEngine, "searxng")
|
||||
_ = s.db.SetConfig(cfgSearchURL, sx.URL)
|
||||
post := func(path, body string) string {
|
||||
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
|
||||
req.AddCookie(c)
|
||||
w := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(w, req)
|
||||
return w.Body.String()
|
||||
}
|
||||
if got := post("/api/panel/ai/search", `{"q":"intel"}`); !strings.Contains(got, `"ok":true`) {
|
||||
t.Fatalf("search by default: %s", got)
|
||||
}
|
||||
if got := post("/api/panel/ai/fetch", `{"url":"не адрес"}`); strings.Contains(got, "выключен") {
|
||||
t.Fatalf("fetch by default: %s", got)
|
||||
}
|
||||
a, _ := s.db.AdminByUsername("adm")
|
||||
_ = s.db.SetConfig(aiUserKey(a.ID), "0")
|
||||
for _, p := range []string{"/api/panel/ai/search", "/api/panel/ai/fetch"} {
|
||||
if got := post(p, `{"q":"intel","url":"https://example.com"}`); !strings.Contains(got, "выключен") {
|
||||
t.Fatalf("%s with the assistant off: %s", p, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// openAIChunk is one streamed delta of an OpenAI-compatible server.
|
||||
func openAIChunk(field, text string) string {
|
||||
b, _ := json.Marshal(map[string]any{"choices": []any{map[string]any{"delta": map[string]string{field: text}}}})
|
||||
return "data: " + string(b) + "\n\n"
|
||||
}
|
||||
|
||||
// A model that thinks by itself (llama.cpp, vLLM: reasoning_content) has its
|
||||
// thinking shown as the chat's own: between ::думаю:: and ::ответ::.
|
||||
func TestAIOpenAINativeReasoning(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, openAIChunk("reasoning_content", "17*23")+openAIChunk("reasoning_content", " = 391")+
|
||||
openAIChunk("content", "391")+"data: [DONE]\n\n")
|
||||
}))
|
||||
defer srv.Close()
|
||||
got, err := collect(t, aiConfig{Type: "openai", URL: srv.URL, Model: "m", think: "on"})
|
||||
if err != nil || got != "::думаю::\n17*23 = 391\n::ответ::\n391" {
|
||||
t.Fatalf("got %q, %v", got, err)
|
||||
}
|
||||
kw, _ := body["chat_template_kwargs"].(map[string]any)
|
||||
if kw["enable_thinking"] != true {
|
||||
t.Fatalf("thinking not asked for: %v", body)
|
||||
}
|
||||
}
|
||||
|
||||
// A server that does not know the thinking switch (400 on it) is asked again
|
||||
// without it.
|
||||
func TestAIOpenAIRetriesWithoutThinkSwitch(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
var calls []map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var b map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&b)
|
||||
mu.Lock()
|
||||
calls = append(calls, b)
|
||||
mu.Unlock()
|
||||
if _, ok := b["chat_template_kwargs"]; ok {
|
||||
http.Error(w, `{"error":{"message":"Unrecognized request argument supplied: chat_template_kwargs"}}`, 400)
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, openAIChunk("content", "ok")+"data: [DONE]\n\n")
|
||||
}))
|
||||
defer srv.Close()
|
||||
got, err := collect(t, aiConfig{Type: "openai", URL: srv.URL, Model: "m", think: "off"})
|
||||
if err != nil || got != "ok" || len(calls) != 2 {
|
||||
t.Fatalf("got %q, %v, %d calls", got, err, len(calls))
|
||||
}
|
||||
}
|
||||
|
||||
// Anthropic: «Думать» turns on extended thinking; its thinking_delta is the
|
||||
// thinking shown.
|
||||
func TestAIAnthropicThinking(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: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"хм\"}}\n\n"+
|
||||
"data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"Да\"}}\n\n"+
|
||||
"data: {\"type\":\"message_stop\"}\n\n")
|
||||
}))
|
||||
defer srv.Close()
|
||||
got, err := collect(t, aiConfig{Type: "anthropic", URL: srv.URL, Key: "k", Model: "m", think: "on"})
|
||||
if err != nil || got != "::думаю::\nхм\n::ответ::\nДа" {
|
||||
t.Fatalf("got %q, %v", got, err)
|
||||
}
|
||||
th, _ := body["thinking"].(map[string]any)
|
||||
if th["type"] != "enabled" || body["max_tokens"].(float64) <= th["budget_tokens"].(float64) {
|
||||
t.Fatalf("thinking not asked for: %v", body)
|
||||
}
|
||||
}
|
||||
|
||||
// askThink sends one «Думать» question to the chat and gives the answer and
|
||||
// the system prompts the model got, in order.
|
||||
func askThink(t *testing.T, reply func(system string, body map[string]any) string) (string, []string) {
|
||||
t.Helper()
|
||||
s, c := newTestServer(t)
|
||||
var mu sync.Mutex
|
||||
var systems []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var b map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&b)
|
||||
sys := b["messages"].([]any)[0].(map[string]any)["content"].(string)
|
||||
if strings.Contains(fmt.Sprint(b["messages"]), "название чата") || strings.Contains(sys, "название чата") {
|
||||
fmt.Fprint(w, openAIChunk("content", "Чат")+"data: [DONE]\n\n")
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
systems = append(systems, sys)
|
||||
mu.Unlock()
|
||||
fmt.Fprint(w, reply(sys, b)+"data: [DONE]\n\n")
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
postAIModels(t, s, c, [][7]string{{"", "M", "openai", srv.URL, "m", "", ""}})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/panel/ai/chat", strings.NewReader(`{"message":"сколько 17*23","context":"x","think":true}`))
|
||||
req.AddCookie(c)
|
||||
w := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(w, req)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return w.Body.String(), append([]string(nil), systems...)
|
||||
}
|
||||
|
||||
// A model with thinking of its own thinks once, by itself: no "think in
|
||||
// words" prompt on top.
|
||||
func TestAIChatThinkNative(t *testing.T) {
|
||||
got, systems := askThink(t, func(sys string, b map[string]any) string {
|
||||
return openAIChunk("reasoning_content", "считаю") + openAIChunk("content", "391")
|
||||
})
|
||||
if len(systems) != 1 || strings.Contains(systems[0], "Режим размышления") {
|
||||
t.Fatalf("systems: %q", systems)
|
||||
}
|
||||
if !strings.Contains(got, "::думаю::\nсчитаю\n::ответ::\n391") {
|
||||
t.Fatalf("answer: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A model without it is asked again with the prompt, its first answer
|
||||
// dropped unseen.
|
||||
func TestAIChatThinkPromptFallback(t *testing.T) {
|
||||
got, systems := askThink(t, func(sys string, b map[string]any) string {
|
||||
if strings.Contains(sys, "Режим размышления") {
|
||||
return openAIChunk("content", "::думаю::\nсчитаю\n::ответ::\n391")
|
||||
}
|
||||
return openAIChunk("content", "сразу ответ")
|
||||
})
|
||||
if len(systems) != 2 || strings.Contains(systems[0], "Режим размышления") || !strings.Contains(systems[1], "Режим размышления") {
|
||||
t.Fatalf("systems: %q", systems)
|
||||
}
|
||||
if strings.Contains(got, "сразу ответ") || !strings.Contains(got, "::ответ::\n391") {
|
||||
t.Fatalf("answer: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A chat deleted takes with it what the user attached to it: the text of
|
||||
// its files lives in the messages, and must be gone from the database
|
||||
// files too - not just from the tables (SQLite leaves deleted rows in its
|
||||
// free pages and in the write-ahead log).
|
||||
func TestDeleteChatWipesAttachments(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "t.db")
|
||||
db, err := OpenDB(path)
|
||||
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)
|
||||
}
|
||||
adm, _ := db.AdminByUsername("adm")
|
||||
me := adm.ID
|
||||
const secret = "TOP-SECRET-CONTENT-OF-AN-ATTACHED-FILE-7731"
|
||||
chat := s.aiNewChat(me)
|
||||
other := s.aiNewChat(me)
|
||||
s.aiAppendTo(me, chat, "user", "посмотри\n\n[Вложение: notes.txt]\nСодержимое файла:\n"+secret)
|
||||
s.aiAppendTo(me, chat, "result", "Картинка готова [картинка: https://s2.x-grk.com/uploads/userphoto/ai/zzz-7731.png]")
|
||||
s.aiAppendTo(me, other, "user", "другой чат, остаётся")
|
||||
|
||||
s.aiDeleteChat(me, chat)
|
||||
|
||||
var n int
|
||||
_ = db.sql.QueryRow(`SELECT COUNT(*) FROM ai_messages WHERE chat_id = ?`, chat).Scan(&n)
|
||||
if n != 0 {
|
||||
t.Fatalf("%d messages of the deleted chat remain", n)
|
||||
}
|
||||
_ = db.sql.QueryRow(`SELECT COUNT(*) FROM ai_messages WHERE chat_id = ?`, other).Scan(&n)
|
||||
if n != 1 {
|
||||
t.Fatalf("the other chat has %d messages, want 1", n)
|
||||
}
|
||||
for _, f := range []string{path, path + "-wal"} {
|
||||
b, _ := os.ReadFile(f)
|
||||
if bytes.Contains(b, []byte(secret)) || bytes.Contains(b, []byte("zzz-7731")) {
|
||||
t.Fatalf("the deleted chat's content is still in %s", filepath.Base(f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The oldest chats go past the limit the same way: nothing of them is kept.
|
||||
func TestOldChatsWipedToo(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "t.db")
|
||||
db, err := OpenDB(path)
|
||||
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)
|
||||
}
|
||||
adm, _ := db.AdminByUsername("adm")
|
||||
me := adm.ID
|
||||
first := s.aiNewChat(me)
|
||||
s.aiAppendTo(me, first, "user", "[Вложение: a.txt]\nСодержимое файла:\nOLD-ATTACHMENT-TEXT-5512")
|
||||
for i := 0; i < aiChatsKeep; i++ {
|
||||
s.aiNewChat(me)
|
||||
}
|
||||
if s.aiOwns(me, first) {
|
||||
t.Fatal("the oldest chat is kept past the limit")
|
||||
}
|
||||
for _, f := range []string{path, path + "-wal"} {
|
||||
b, _ := os.ReadFile(f)
|
||||
if bytes.Contains(b, []byte("OLD-ATTACHMENT-TEXT-5512")) {
|
||||
t.Fatalf("the old chat's content is still in %s", filepath.Base(f))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
// 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
|
||||
}
|
||||
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())
|
||||
}
|
||||
for i := range b {
|
||||
b[i] = alphabet[int(b[i])%len(alphabet)]
|
||||
// 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)
|
||||
}
|
||||
+307
-28
@@ -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
|
||||
@@ -19,7 +20,7 @@ type DB struct {
|
||||
func OpenDB(path string) (*DB, error) {
|
||||
// _pragma busy_timeout avoids "database is locked" under the concurrent
|
||||
// long-poll load; foreign_keys keeps the schema honest.
|
||||
dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)", path)
|
||||
dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)&_pragma=secure_delete(1)", path)
|
||||
sdb, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -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,22 @@ 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
|
||||
}
|
||||
|
||||
// wipeDeleted makes what was deleted leave the database's files: with
|
||||
// secure_delete the freed pages are overwritten, and the write-ahead log,
|
||||
// which still holds the rows as they were written, is emptied (SQLite only;
|
||||
// PostgreSQL cleans up for itself). The attachments of a chat deleted are
|
||||
// among that.
|
||||
func (db *DB) wipeDeleted() {
|
||||
if db.sql.pg {
|
||||
return
|
||||
}
|
||||
_, _ = db.sql.Exec(`PRAGMA wal_checkpoint(TRUNCATE)`)
|
||||
}
|
||||
@@ -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 !s.aiOn(a.ID) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
+32
-11
@@ -12,8 +12,9 @@ import (
|
||||
|
||||
// forwardManager owns the host-side listeners for port-forward rules. It
|
||||
// supports three transports:
|
||||
// - "http": tunneled over the HTTP long-poll channel (works anywhere nginx
|
||||
// reaches, highest latency);
|
||||
// - "http": over the agent's own link to the panel - its WebSocket, or the
|
||||
// HTTP long polls while there is none (works anywhere nginx reaches,
|
||||
// highest latency);
|
||||
// - "tcp": direct TCP multiplexed over the tunnel port (low latency);
|
||||
// - "udp": direct UDP over the tunnel port.
|
||||
type forwardManager struct {
|
||||
@@ -103,7 +104,8 @@ func (m *forwardManager) bridgeDirect(rule ForwardRule, conn net.Conn) {
|
||||
at.openStream(rule.ClientPort, conn)
|
||||
}
|
||||
|
||||
// bridgeHTTP wires a connection through the HTTP long-poll hub session.
|
||||
// bridgeHTTP wires a connection through a hub session: the agent carries it
|
||||
// over its WebSocket, or by long polls while it has none.
|
||||
func (m *forwardManager) bridgeHTTP(rule ForwardRule, conn net.Conn) {
|
||||
sess := m.hub.CreateForward(rule.AgentID, rule.ClientPort)
|
||||
defer m.hub.CloseSession(sess.ID)
|
||||
@@ -127,20 +129,39 @@ func (m *forwardManager) bridgeHTTP(rule ForwardRule, conn net.Conn) {
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-sess.Done():
|
||||
return
|
||||
case out, ok := <-sess.Out():
|
||||
if !ok || out.Exit {
|
||||
return
|
||||
// write passes one message on; false: the stream is over.
|
||||
write := func(out proto.OutMessage) bool {
|
||||
if out.Exit {
|
||||
return false
|
||||
}
|
||||
if out.Data != "" {
|
||||
b, err := base64.StdEncoding.DecodeString(out.Data)
|
||||
if err != nil {
|
||||
return
|
||||
return false
|
||||
}
|
||||
if _, err := conn.Write(b); err != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case out, ok := <-sess.Out():
|
||||
if !ok || !write(out) {
|
||||
return
|
||||
}
|
||||
case <-sess.Done():
|
||||
// The agent's end of stream closes the session at once, while
|
||||
// what it sent before may still be queued (a download read
|
||||
// faster than the browser takes it): that goes out first.
|
||||
for {
|
||||
select {
|
||||
case out := <-sess.Out():
|
||||
if !write(out) {
|
||||
return
|
||||
}
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"io"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||
)
|
||||
|
||||
// An http-lp download the agent has finished (its last chunk, then the end)
|
||||
// reaches the browser whole: the session closing does not cut off what is
|
||||
// still queued for it.
|
||||
func TestForwardHTTPDrainsAfterClose(t *testing.T) {
|
||||
hub := NewHub()
|
||||
m := &forwardManager{hub: hub}
|
||||
user, host := net.Pipe()
|
||||
go m.bridgeHTTP(ForwardRule{AgentID: "a1", ClientPort: 80, Proto: "http"}, host)
|
||||
|
||||
var sess *Session
|
||||
for i := 0; i < 100 && sess == nil; i++ {
|
||||
hub.mu.Lock()
|
||||
for _, s := range hub.sessions {
|
||||
sess = s
|
||||
}
|
||||
hub.mu.Unlock()
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if sess == nil {
|
||||
t.Fatal("no session")
|
||||
}
|
||||
want := bytes.Repeat([]byte("0123456789abcdef"), 4096) // 64 KiB in 64 chunks
|
||||
go func() {
|
||||
for i := 0; i < len(want); i += 1024 {
|
||||
sess.PushOut(proto.OutMessage{Data: base64.StdEncoding.EncodeToString(want[i : i+1024])})
|
||||
}
|
||||
sess.PushOut(proto.OutMessage{Exit: true})
|
||||
sess.close() // as the server does on the agent's end of stream
|
||||
}()
|
||||
time.Sleep(50 * time.Millisecond) // all of it queued, the session closed, before the browser reads
|
||||
_ = user.SetReadDeadline(time.Now().Add(5 * time.Second))
|
||||
got, _ := io.ReadAll(user)
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Fatalf("got %d of %d bytes", len(got), len(want))
|
||||
}
|
||||
}
|
||||
@@ -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,159 @@
|
||||
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(`{"aiModelPick":"always","aiModel":"a1b2c3"}`)
|
||||
if got := get(t, s, c, "/api/panel/account/prefs").Body.String(); !strings.Contains(got, `"aiModelPick":"always"`) || !strings.Contains(got, `"aiModel":"a1b2c3"`) {
|
||||
t.Fatalf("the model choice not kept: %s", got)
|
||||
}
|
||||
post(`{"aiModelPick":"often","aiModel":"<x>"}`)
|
||||
if got := get(t, s, c, "/api/panel/account/prefs").Body.String(); strings.Contains(got, "aiModel") {
|
||||
t.Fatalf("an unknown model choice was 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,642 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||
)
|
||||
|
||||
// Files go between the browser and the agent in pieces. Each piece is a
|
||||
// request of its own, small enough for any proxy in front (Cloudflare takes
|
||||
// 100 MB a request at most), and one that fails is sent again instead of the
|
||||
// whole file. Several pieces are in flight at once, so a good link stays as
|
||||
// busy as it would with one long stream. The server holds only a few pieces
|
||||
// of a transfer: whoever sends waits while they wait to be taken.
|
||||
//
|
||||
// A download keeps one plain response towards the browser, which a proxy
|
||||
// passes on whatever its size: links, previews and the hex editor read it.
|
||||
|
||||
const (
|
||||
// xferChunk is the size of a piece.
|
||||
xferChunk = 4 << 20
|
||||
// xferBuffer is how many pieces of a transfer the server holds.
|
||||
xferBuffer = 8
|
||||
// xferWait is how long a request waits for its piece, or for room for it,
|
||||
// before it is told to ask again: well under a proxy's timeout.
|
||||
xferWait = 50 * time.Second
|
||||
// xferIdle ends a transfer nobody has touched for this long.
|
||||
xferIdle = 10 * time.Minute
|
||||
// xferTimeout bounds one transfer, however slow the links.
|
||||
xferTimeout = 2 * time.Hour
|
||||
)
|
||||
|
||||
// fsLegacyCap is the most an agent that predates the pieces can take in or
|
||||
// hand over: the file goes base64 inside a task then.
|
||||
const fsLegacyCap = 32 << 20
|
||||
|
||||
// xfer is one file on its way.
|
||||
type xfer struct {
|
||||
id, agentID string
|
||||
|
||||
mu sync.Mutex
|
||||
changed chan struct{} // closed and replaced on every change
|
||||
size int64 // -1 until known
|
||||
n int // pieces in all, once the size is known
|
||||
chunks map[int][]byte
|
||||
// low is the first piece still to go: the ones below it were written by
|
||||
// the agent (upload) or to the browser (download).
|
||||
low int
|
||||
sized chan struct{} // closed once the size is known
|
||||
err string // why the transfer failed
|
||||
done bool
|
||||
result proto.TaskResult
|
||||
last time.Time
|
||||
}
|
||||
|
||||
func pieces(size int64) int {
|
||||
if size <= 0 {
|
||||
return 1 // an empty file is one empty piece
|
||||
}
|
||||
return int((size + xferChunk - 1) / xferChunk)
|
||||
}
|
||||
|
||||
// pieceLen is how long piece i of the file is.
|
||||
func (x *xfer) pieceLen(i int) int64 {
|
||||
return min(int64(xferChunk), x.size-int64(i)*xferChunk)
|
||||
}
|
||||
|
||||
// notify wakes whoever waits on the transfer; x.mu is held.
|
||||
func (x *xfer) notify() {
|
||||
x.last = time.Now()
|
||||
close(x.changed)
|
||||
x.changed = make(chan struct{})
|
||||
}
|
||||
|
||||
// await waits until ready() holds and returns true with x.mu held; false,
|
||||
// with it free, when the wait ran out or ctx ended.
|
||||
func (x *xfer) await(ctx context.Context, d time.Duration, ready func() bool) bool {
|
||||
t := time.NewTimer(d)
|
||||
defer t.Stop()
|
||||
for {
|
||||
x.mu.Lock()
|
||||
if ready() {
|
||||
return true
|
||||
}
|
||||
ch := x.changed
|
||||
x.mu.Unlock()
|
||||
select {
|
||||
case <-ch:
|
||||
case <-t.C:
|
||||
return false
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (x *xfer) fail(msg string) {
|
||||
x.mu.Lock()
|
||||
if x.err == "" && !x.done {
|
||||
x.err = msg
|
||||
x.chunks = nil
|
||||
x.notify()
|
||||
}
|
||||
x.mu.Unlock()
|
||||
}
|
||||
|
||||
func (x *xfer) finish(res proto.TaskResult) {
|
||||
x.mu.Lock()
|
||||
x.done, x.result, x.chunks = true, res, nil
|
||||
x.notify()
|
||||
x.mu.Unlock()
|
||||
}
|
||||
|
||||
// setSize records the file's size; false when it differs from the one known.
|
||||
func (x *xfer) setSize(size int64) bool {
|
||||
x.mu.Lock()
|
||||
defer x.mu.Unlock()
|
||||
if x.size >= 0 {
|
||||
return x.size == size
|
||||
}
|
||||
x.size, x.n = size, pieces(size)
|
||||
close(x.sized)
|
||||
x.notify()
|
||||
return true
|
||||
}
|
||||
|
||||
// put stores piece i once there is room for it, and answers with the HTTP
|
||||
// status for its sender. A piece already taken or held is accepted again: it
|
||||
// is a retry.
|
||||
func (x *xfer) put(ctx context.Context, i int, data []byte) (int, string) {
|
||||
if !x.await(ctx, xferWait, func() bool { return x.err != "" || x.done || i < x.low+xferBuffer }) {
|
||||
return http.StatusServiceUnavailable, "нет места, повторите"
|
||||
}
|
||||
defer x.mu.Unlock()
|
||||
if x.err != "" || x.done {
|
||||
return http.StatusGone, x.gone()
|
||||
}
|
||||
if i < 0 || i >= x.n || int64(len(data)) != x.pieceLen(i) {
|
||||
return http.StatusBadRequest, "не тот кусок"
|
||||
}
|
||||
if i >= x.low && x.chunks[i] == nil {
|
||||
x.chunks[i] = data
|
||||
x.notify()
|
||||
}
|
||||
return http.StatusNoContent, ""
|
||||
}
|
||||
|
||||
// take hands over piece i, the next one in order, and lets it go.
|
||||
func (x *xfer) take(ctx context.Context, i int, d time.Duration) ([]byte, error) {
|
||||
if !x.await(ctx, d, func() bool { return x.err != "" || x.chunks[i] != nil }) {
|
||||
return nil, errors.New("кусок не пришёл вовремя")
|
||||
}
|
||||
defer x.mu.Unlock()
|
||||
if x.err != "" {
|
||||
return nil, errors.New(x.err)
|
||||
}
|
||||
data := x.chunks[i]
|
||||
delete(x.chunks, i)
|
||||
x.low = i + 1
|
||||
x.notify()
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (x *xfer) gone() string {
|
||||
if x.err != "" {
|
||||
return x.err
|
||||
}
|
||||
return "передача закончена"
|
||||
}
|
||||
|
||||
// Read makes an upload a plain reader, for an agent that takes a file whole.
|
||||
type xferReader struct {
|
||||
ctx context.Context
|
||||
x *xfer
|
||||
i int
|
||||
buf []byte
|
||||
}
|
||||
|
||||
func (r *xferReader) Read(p []byte) (int, error) {
|
||||
for len(r.buf) == 0 {
|
||||
if r.i >= r.x.n {
|
||||
return 0, io.EOF
|
||||
}
|
||||
data, err := r.x.take(r.ctx, r.i, xferIdle)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
r.buf, r.i = data, r.i+1
|
||||
}
|
||||
n := copy(p, r.buf)
|
||||
r.buf = r.buf[n:]
|
||||
return n, nil
|
||||
}
|
||||
|
||||
type xfers struct {
|
||||
mu sync.Mutex
|
||||
m map[string]*xfer
|
||||
}
|
||||
|
||||
func (c *xfers) open(agentID string, size int64) *xfer {
|
||||
x := &xfer{
|
||||
id: randToken()[:24], agentID: agentID, size: -1,
|
||||
changed: make(chan struct{}), chunks: map[int][]byte{},
|
||||
sized: make(chan struct{}), last: time.Now(),
|
||||
}
|
||||
if size >= 0 {
|
||||
x.size, x.n = size, pieces(size)
|
||||
close(x.sized)
|
||||
}
|
||||
c.mu.Lock()
|
||||
if c.m == nil {
|
||||
c.m = map[string]*xfer{}
|
||||
}
|
||||
// Forget the ones left behind: a closed tab, a client gone for good.
|
||||
for id, old := range c.m {
|
||||
old.mu.Lock()
|
||||
stale := time.Since(old.last) > xferIdle
|
||||
old.mu.Unlock()
|
||||
if stale {
|
||||
old.fail("передача брошена")
|
||||
delete(c.m, id)
|
||||
}
|
||||
}
|
||||
c.m[x.id] = x
|
||||
c.mu.Unlock()
|
||||
return x
|
||||
}
|
||||
|
||||
func (c *xfers) get(id string) *xfer {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.m[id]
|
||||
}
|
||||
|
||||
// forAgent returns the transfer if it is the asking agent's.
|
||||
func (c *xfers) forAgent(id, agentID string) *xfer {
|
||||
if x := c.get(id); x != nil && x.agentID == agentID {
|
||||
return x
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *xfers) drop(id string) {
|
||||
c.mu.Lock()
|
||||
delete(c.m, id)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// waitTask runs a task on the agent and waits for its result, or for ctx to
|
||||
// end.
|
||||
func (s *Server) waitTask(ctx context.Context, agentID, typ string, args any, timeout time.Duration) (proto.TaskResult, error) {
|
||||
raw, _ := json.Marshal(args)
|
||||
taskID, ch := s.hub.CreateTask(agentID, typ, raw)
|
||||
defer s.hub.DiscardTask(taskID)
|
||||
t := time.NewTimer(timeout)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case res := <-ch:
|
||||
return res, nil
|
||||
case <-ctx.Done():
|
||||
return proto.TaskResult{}, ctx.Err()
|
||||
case <-t.C:
|
||||
return proto.TaskResult{}, errors.New("таймаут ожидания агента")
|
||||
}
|
||||
}
|
||||
|
||||
// unknownOp tells an agent that predates the task.
|
||||
func unknownOp(res proto.TaskResult) bool {
|
||||
return !res.OK && strings.Contains(res.Error, "неизвестная операция")
|
||||
}
|
||||
|
||||
// fsAgent answers the request itself and returns false when the agent is
|
||||
// unknown or offline.
|
||||
func (s *Server) fsAgent(w http.ResponseWriter, agentID string, asJSON bool) bool {
|
||||
a, err := s.db.GetAgent(agentID)
|
||||
if err != nil || a == nil {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return false
|
||||
}
|
||||
if !s.online(a) {
|
||||
if asJSON {
|
||||
writeJSON(w, proto.TaskResult{Error: "машина офлайн"})
|
||||
} else {
|
||||
http.Error(w, "машина офлайн", http.StatusServiceUnavailable)
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// readPiece reads a piece's body into a buffer of its own size, so pieces
|
||||
// leave no trail of grown buffers behind.
|
||||
func readPiece(w http.ResponseWriter, r *http.Request) ([]byte, error) {
|
||||
if r.ContentLength >= 0 && r.ContentLength <= xferChunk {
|
||||
data := make([]byte, r.ContentLength)
|
||||
_, err := io.ReadFull(r.Body, data)
|
||||
return data, err
|
||||
}
|
||||
return io.ReadAll(http.MaxBytesReader(w, r.Body, xferChunk))
|
||||
}
|
||||
|
||||
// ---------- upload: browser -> agent ----------
|
||||
|
||||
// POST /api/panel/agents/{id}/fs/upload?path=...&size=N - start writing a
|
||||
// file. The answer names the transfer and the piece size; the browser then
|
||||
// PUTs the pieces and asks for the result.
|
||||
func (s *Server) handleFSUploadStart(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
}
|
||||
size, err := strconv.ParseInt(r.URL.Query().Get("size"), 10, 64)
|
||||
if p == "" || err != nil || size < 0 {
|
||||
writeJSON(w, proto.TaskResult{Error: "не указан путь или размер"})
|
||||
return
|
||||
}
|
||||
if size > proto.FSFileCap {
|
||||
writeJSON(w, proto.TaskResult{Error: "файл больше 512 МБ"})
|
||||
return
|
||||
}
|
||||
x := s.xfers.open(agentID, size)
|
||||
go s.runUpload(x, p, 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, 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, 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 {
|
||||
res = proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
if !res.OK {
|
||||
x.fail(res.Error)
|
||||
}
|
||||
x.finish(res)
|
||||
// The result waits a while for the browser to come for it.
|
||||
time.AfterFunc(xferIdle, func() { s.xfers.drop(x.id) })
|
||||
}
|
||||
|
||||
// legacyUpload writes a file through an agent that predates the pieces.
|
||||
func (s *Server) legacyUpload(ctx context.Context, x *xfer, p string) proto.TaskResult {
|
||||
if x.size > fsLegacyCap {
|
||||
return proto.TaskResult{Error: "клиент принимает файлы до 32 МБ - обновите его"}
|
||||
}
|
||||
data, err := io.ReadAll(&xferReader{ctx: ctx, x: x})
|
||||
if err != nil {
|
||||
return proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
args := proto.FSWriteArgs{Path: p, Data: base64.StdEncoding.EncodeToString(data)}
|
||||
res, err := s.waitTask(ctx, x.agentID, proto.TaskFSWrite, args, fsTaskTimeout)
|
||||
if err != nil {
|
||||
return proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// PUT /api/panel/fs/xfer/{xid}/{i} - piece i of a file being uploaded. 503:
|
||||
// no room yet, send it again; 410: the transfer is over.
|
||||
func (s *Server) handleXferPut(w http.ResponseWriter, r *http.Request) {
|
||||
x := s.xfers.get(r.PathValue("xid"))
|
||||
i, err := strconv.Atoi(r.PathValue("i"))
|
||||
if x == nil || err != nil {
|
||||
http.Error(w, "передача не найдена", http.StatusGone)
|
||||
return
|
||||
}
|
||||
data, err := readPiece(w, r)
|
||||
if err != nil {
|
||||
http.Error(w, "кусок не дошёл", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
code, msg := x.put(r.Context(), i, data)
|
||||
if code == http.StatusNoContent {
|
||||
w.WriteHeader(code)
|
||||
return
|
||||
}
|
||||
http.Error(w, msg, code)
|
||||
}
|
||||
|
||||
// POST /api/panel/fs/xfer/{xid}/cancel - the browser gives the upload up.
|
||||
func (s *Server) handleXferCancel(w http.ResponseWriter, r *http.Request) {
|
||||
if x := s.xfers.get(r.PathValue("xid")); x != nil {
|
||||
x.fail("отменено")
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// GET /api/panel/fs/xfer/{xid}/result - the upload's result, once the agent
|
||||
// has written the file; {"done":false} means ask again.
|
||||
func (s *Server) handleXferResult(w http.ResponseWriter, r *http.Request) {
|
||||
x := s.xfers.get(r.PathValue("xid"))
|
||||
if x == nil {
|
||||
writeJSON(w, map[string]any{"done": true, "result": proto.TaskResult{Error: "передача не найдена"}})
|
||||
return
|
||||
}
|
||||
if !x.await(r.Context(), xferWait, func() bool { return x.done }) {
|
||||
writeJSON(w, map[string]any{"done": false})
|
||||
return
|
||||
}
|
||||
res := x.result
|
||||
x.mu.Unlock()
|
||||
writeJSON(w, map[string]any{"done": true, "result": res})
|
||||
}
|
||||
|
||||
// GET /api/agent/fs/xfer/{xid}/{i}?ack=K - piece i of the upload, for the
|
||||
// agent to write. K says the agent has written every piece below it, so
|
||||
// those are let go. 503: not here yet, ask again; 410: the transfer is over.
|
||||
func (s *Server) handleAgentXferGet(w http.ResponseWriter, r *http.Request) {
|
||||
a := s.authenticateAgent(r)
|
||||
if a == nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
x := s.xfers.forAgent(r.PathValue("xid"), a.ID)
|
||||
i, err := strconv.Atoi(r.PathValue("i"))
|
||||
if x == nil || err != nil {
|
||||
http.Error(w, "передача не найдена", http.StatusGone)
|
||||
return
|
||||
}
|
||||
if ack, err := strconv.Atoi(r.URL.Query().Get("ack")); err == nil {
|
||||
x.mu.Lock()
|
||||
if ack > x.low {
|
||||
for k := x.low; k < ack; k++ {
|
||||
delete(x.chunks, k)
|
||||
}
|
||||
x.low = ack
|
||||
x.notify()
|
||||
}
|
||||
x.mu.Unlock()
|
||||
}
|
||||
if !x.await(r.Context(), xferWait, func() bool { return x.err != "" || x.chunks[i] != nil }) {
|
||||
http.Error(w, "кусок ещё не пришёл", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if x.err != "" {
|
||||
msg := x.err
|
||||
x.mu.Unlock()
|
||||
http.Error(w, msg, http.StatusGone)
|
||||
return
|
||||
}
|
||||
data := x.chunks[i]
|
||||
x.mu.Unlock()
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
// ---------- download: agent -> browser ----------
|
||||
|
||||
// inlineTypes are the files the panel shows in place rather than downloads.
|
||||
var inlineTypes = map[string]string{
|
||||
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif",
|
||||
".webp": "image/webp", ".bmp": "image/bmp", ".ico": "image/x-icon", ".avif": "image/avif",
|
||||
".svg": "image/svg+xml",
|
||||
".mp4": "video/mp4", ".m4v": "video/mp4", ".webm": "video/webm", ".ogv": "video/ogg", ".mov": "video/quicktime",
|
||||
".mp3": "audio/mpeg", ".wav": "audio/wav", ".ogg": "audio/ogg", ".oga": "audio/ogg", ".m4a": "audio/mp4",
|
||||
".aac": "audio/aac", ".flac": "audio/flac", ".opus": "audio/ogg",
|
||||
".pdf": "application/pdf",
|
||||
}
|
||||
|
||||
// fileHeaders sets what the browser needs to download or show the file. A
|
||||
// file shown in place runs nothing: no scripts in an SVG, whatever it holds.
|
||||
// A PDF is the exception, as the browser's viewer will not open in a sandbox;
|
||||
// it runs in that viewer, not in the panel's page.
|
||||
func fileHeaders(w http.ResponseWriter, p string, inline bool) {
|
||||
name := path.Base(p)
|
||||
ctype := "application/octet-stream"
|
||||
if t, ok := inlineTypes[strings.ToLower(path.Ext(name))]; ok && inline {
|
||||
ctype = t
|
||||
}
|
||||
disp := "attachment"
|
||||
if inline {
|
||||
disp = "inline"
|
||||
}
|
||||
w.Header().Set("Content-Type", ctype)
|
||||
w.Header().Set("Content-Disposition", disp+"; filename=\""+strings.ReplaceAll(name, "\"", "")+"\"")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
if ctype != "application/pdf" {
|
||||
w.Header().Set("Content-Security-Policy", "sandbox; default-src 'none'; img-src data:; style-src 'unsafe-inline'")
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
}
|
||||
|
||||
// GET /api/panel/agents/{id}/fs/download?path=...[&inline=1] - the file, as
|
||||
// one response fed by the pieces the agent sends. inline=1 serves pictures,
|
||||
// video and sound for the page to show.
|
||||
func (s *Server) handleFSDownload(w http.ResponseWriter, r *http.Request) {
|
||||
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) {
|
||||
return
|
||||
}
|
||||
x := s.xfers.open(agentID, -1)
|
||||
defer s.xfers.drop(x.id)
|
||||
defer x.fail("браузер ушёл") // stops the agent if the response ends early
|
||||
|
||||
raw, _ := json.Marshal(proto.FSXferArgs{Path: p, Xfer: x.id, Size: -1, Chunk: xferChunk, 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)
|
||||
go func() {
|
||||
select {
|
||||
case res := <-ch:
|
||||
if !res.OK && !unknownOp(res) {
|
||||
x.fail(res.Error)
|
||||
}
|
||||
results <- res
|
||||
case <-r.Context().Done():
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-x.sized:
|
||||
case res := <-results:
|
||||
if unknownOp(res) && task != proto.TaskFSSend {
|
||||
http.Error(w, "агент не умеет файлы по SSH - обновите его", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
if unknownOp(res) {
|
||||
s.legacyDownload(w, r, agentID, p, inline)
|
||||
return
|
||||
}
|
||||
msg := res.Error
|
||||
if msg == "" {
|
||||
msg = "агент не отдал файл"
|
||||
}
|
||||
http.Error(w, msg, http.StatusBadGateway)
|
||||
return
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case <-time.After(fsTaskTimeout):
|
||||
http.Error(w, "таймаут ожидания агента", http.StatusGatewayTimeout)
|
||||
return
|
||||
}
|
||||
|
||||
fileHeaders(w, p, inline)
|
||||
w.Header().Set("Content-Length", strconv.FormatInt(x.size, 10))
|
||||
rc := http.NewResponseController(w)
|
||||
for i := 0; i < x.n; i++ {
|
||||
data, err := x.take(r.Context(), i, xferIdle)
|
||||
if err == nil {
|
||||
_, err = w.Write(data)
|
||||
}
|
||||
if err != nil {
|
||||
// Cut the response off, so the browser does not take a part of
|
||||
// the file for all of it.
|
||||
x.fail(err.Error())
|
||||
panic(http.ErrAbortHandler)
|
||||
}
|
||||
_ = rc.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
// legacyDownload gets a file from an agent that predates the pieces.
|
||||
func (s *Server) legacyDownload(w http.ResponseWriter, r *http.Request, agentID, p string, inline bool) {
|
||||
res, err := s.waitTask(r.Context(), agentID, proto.TaskFSDownload, proto.FSPathArgs{Path: p}, fsTaskTimeout)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusGatewayTimeout)
|
||||
return
|
||||
}
|
||||
if !res.OK {
|
||||
http.Error(w, res.Error, http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
var rr proto.FSReadResult
|
||||
if err := json.Unmarshal(res.Data, &rr); err != nil {
|
||||
http.Error(w, "bad result", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
data, _ := base64.StdEncoding.DecodeString(rr.Content)
|
||||
fileHeaders(w, p, inline)
|
||||
if rr.Truncated {
|
||||
w.Header().Set("X-Truncated", "1")
|
||||
}
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
// POST /api/agent/fs/xfer/{xid}/{i} - piece i of the file the browser asked
|
||||
// for, X-Size its whole size. 503: no room yet, send it again; 410: the
|
||||
// browser is gone.
|
||||
func (s *Server) handleAgentXferPost(w http.ResponseWriter, r *http.Request) {
|
||||
a := s.authenticateAgent(r)
|
||||
if a == nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
x := s.xfers.forAgent(r.PathValue("xid"), a.ID)
|
||||
i, err := strconv.Atoi(r.PathValue("i"))
|
||||
if x == nil || err != nil {
|
||||
http.Error(w, "передача не найдена", http.StatusGone)
|
||||
return
|
||||
}
|
||||
size, err := strconv.ParseInt(r.Header.Get("X-Size"), 10, 64)
|
||||
if err != nil || size < 0 || size > proto.FSFileCap || !x.setSize(size) {
|
||||
http.Error(w, "не тот размер", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
data, err := readPiece(w, r)
|
||||
if err != nil {
|
||||
http.Error(w, "кусок не дошёл", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
code, msg := x.put(r.Context(), i, data)
|
||||
if code == http.StatusNoContent {
|
||||
w.WriteHeader(code)
|
||||
return
|
||||
}
|
||||
http.Error(w, msg, code)
|
||||
}
|
||||
@@ -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),
|
||||
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
|
||||
@@ -147,30 +159,24 @@ func (s *Server) handlePresence(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
type item struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Hostname string `json:"hostname"`
|
||||
User string `json:"user"` // who a terminal opens as
|
||||
Online bool `json:"online"`
|
||||
Seen string `json:"seen"`
|
||||
}
|
||||
me := s.currentAdmin(r)
|
||||
out := make([]item, 0, len(agents))
|
||||
for _, a := range agents {
|
||||
if !s.db.CanUseAgent(me, a.ID) {
|
||||
continue
|
||||
}
|
||||
on := s.online(&a)
|
||||
out = append(out, item{ID: a.ID, 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,52 @@ 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,
|
||||
"AIModels": s.aiModels(),
|
||||
"Search": s.searchConf(),
|
||||
"ImageKey": func() string { _, k := s.imageConf(); return k }(),
|
||||
"DBNow": dbNow,
|
||||
"DBChosen": dbChosen,
|
||||
"UfwInstalled": ufwInstalled,
|
||||
"UfwActive": ufwActive,
|
||||
"UfwRunnable": ufwRunnable,
|
||||
@@ -397,6 +460,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 +534,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 +558,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 +584,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 +608,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 +661,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,20 +669,48 @@ 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(),
|
||||
"Host": host,
|
||||
"Fallback": fallback,
|
||||
"Secret": secret,
|
||||
}); err != nil {
|
||||
log.Printf("install.sh render: %v", err)
|
||||
@@ -596,3 +723,11 @@ func (s *Server) downloadHandler() http.Handler {
|
||||
// install one-liner works before the machine is enrolled.
|
||||
return http.FileServer(http.Dir(s.distDir))
|
||||
}
|
||||
|
||||
// viaDirectOf tells whether the agent last reached the server at the direct
|
||||
// address.
|
||||
func viaDirectOf(m *sync.Map, id string) bool {
|
||||
v, _ := m.Load(id)
|
||||
b, _ := v.(bool)
|
||||
return b
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"path"
|
||||
"time"
|
||||
|
||||
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||
@@ -22,21 +20,21 @@ var fsOps = map[string]string{
|
||||
"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.
|
||||
@@ -63,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)
|
||||
|
||||
@@ -73,44 +91,3 @@ func (s *Server) handleFSOp(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, proto.TaskResult{Error: "таймаут ожидания агента"})
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/panel/agents/{id}/fs/download?path=... - stream a file as a download.
|
||||
func (s *Server) handleFSDownload(w http.ResponseWriter, r *http.Request) {
|
||||
agentID := r.PathValue("id")
|
||||
p := r.URL.Query().Get("path")
|
||||
a, err := s.db.GetAgent(agentID)
|
||||
if err != nil || a == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if !s.online(a) {
|
||||
http.Error(w, "машина офлайн", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
args, _ := json.Marshal(proto.FSPathArgs{Path: p})
|
||||
taskID, ch := s.hub.CreateTask(agentID, proto.TaskFSDownload, args)
|
||||
defer s.hub.DiscardTask(taskID)
|
||||
|
||||
select {
|
||||
case res := <-ch:
|
||||
if !res.OK {
|
||||
http.Error(w, res.Error, http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
var rr proto.FSReadResult
|
||||
if err := json.Unmarshal(res.Data, &rr); err != nil {
|
||||
http.Error(w, "bad result", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
data, _ := base64.StdEncoding.DecodeString(rr.Content)
|
||||
name := path.Base(p)
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=\""+name+"\"")
|
||||
if rr.Truncated {
|
||||
w.Header().Set("X-Truncated", "1")
|
||||
}
|
||||
_, _ = w.Write(data)
|
||||
case <-time.After(fsTaskTimeout):
|
||||
http.Error(w, "таймаут ожидания агента", http.StatusGatewayTimeout)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -49,6 +97,9 @@ type panelInMsg struct {
|
||||
D string `json:"d,omitempty"`
|
||||
Cols uint16 `json:"cols,omitempty"`
|
||||
Rows uint16 `json:"rows,omitempty"`
|
||||
// Seq numbers the messages of a session from 1; 0 from a page that
|
||||
// predates it.
|
||||
Seq uint64 `json:"seq,omitempty"`
|
||||
}
|
||||
|
||||
// POST /api/panel/term/{sid}/in - send keystrokes/resize to the agent PTY.
|
||||
@@ -63,13 +114,88 @@ func (s *Server) handlePanelIn(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
takePanelIn(sess, m)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// takePanelIn passes the browser's keystrokes or resize on to the agent; a
|
||||
// repeat of one already taken is dropped.
|
||||
func takePanelIn(sess *Session, m panelInMsg) {
|
||||
if m.Seq > 0 && !sess.TakeSeq(m.Seq) {
|
||||
return
|
||||
}
|
||||
switch m.T {
|
||||
case "i":
|
||||
sess.PushIn(proto.InFrame{Type: proto.FrameStdin, Data: m.D})
|
||||
case "r":
|
||||
sess.PushIn(proto.InFrame{Type: proto.FrameResize, Cols: m.Cols, Rows: m.Rows})
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
+46
-4
@@ -7,8 +7,9 @@ import (
|
||||
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||
)
|
||||
|
||||
// Hub is the in-memory broker between agents (HTTP long-polling) and the panel.
|
||||
// It carries three kinds of work to an agent through its single control poll:
|
||||
// Hub is the in-memory broker between agents (on a WebSocket, or by HTTP
|
||||
// long polls) and the panel. It carries three kinds of work to an agent
|
||||
// through its single control channel:
|
||||
// terminal sessions, port-forward streams, and one-shot tasks (file manager).
|
||||
// Nothing here is persisted.
|
||||
type Hub struct {
|
||||
@@ -45,6 +46,7 @@ type Session struct {
|
||||
mu sync.Mutex
|
||||
inBuf []proto.InFrame
|
||||
inSignal chan struct{} // size-1; pulsed when inBuf gains data
|
||||
inSeq uint64 // number of the last browser message taken
|
||||
|
||||
out chan proto.OutMessage // agent -> panel
|
||||
|
||||
@@ -93,9 +95,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
|
||||
}
|
||||
|
||||
@@ -250,6 +259,19 @@ func (s *Session) close() { s.closeOnce.Do(func() { close(s.closed) }) }
|
||||
// Done returns a channel closed when the session ends.
|
||||
func (s *Session) Done() <-chan struct{} { return s.closed }
|
||||
|
||||
// TakeSeq reports whether the browser message numbered seq is new, and takes
|
||||
// note of it. The browser sends them one at a time in rising order and sends
|
||||
// one again when it got no answer, so a number not above the last is a repeat.
|
||||
func (s *Session) TakeSeq(seq uint64) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if seq <= s.inSeq {
|
||||
return false
|
||||
}
|
||||
s.inSeq = seq
|
||||
return true
|
||||
}
|
||||
|
||||
// PushIn appends a downstream frame (panel -> agent) and wakes the agent poll.
|
||||
func (s *Session) PushIn(f proto.InFrame) {
|
||||
s.mu.Lock()
|
||||
@@ -261,6 +283,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)
|
||||
@@ -298,10 +332,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,302 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Pictures for the assistant: the model asks for one in its answer - a
|
||||
// ```image block, a new picture or an edit of a photo - and the panel makes
|
||||
// it through the Vertex API (https://s2.x-grk.com/api/vertex/docs). The
|
||||
// work is a job there: it is put, then asked about until it is done. The
|
||||
// API key is the admins', set in Настройки and never sent to the browser.
|
||||
|
||||
const (
|
||||
cfgImageKey = "ai_image_key"
|
||||
cfgImageURL = "ai_image_url" // not in the settings: the service's address, for tests
|
||||
|
||||
// DefaultImageURL is where the Vertex API is.
|
||||
DefaultImageURL = "https://s2.x-grk.com"
|
||||
|
||||
imageMaxBytes = 20 << 20 // what the service takes of one photo
|
||||
)
|
||||
|
||||
// imageConf is the service's address and the key (none: pictures are off).
|
||||
func (s *Server) imageConf() (base, key string) {
|
||||
base, _ = s.db.GetConfig(cfgImageURL)
|
||||
if base == "" {
|
||||
base = DefaultImageURL
|
||||
}
|
||||
key, _ = s.db.GetConfig(cfgImageKey)
|
||||
return strings.TrimRight(base, "/"), strings.TrimSpace(key)
|
||||
}
|
||||
|
||||
// imagePrompt tells the model it can make pictures, and how - when there
|
||||
// is a key.
|
||||
func (s *Server) imagePrompt() string {
|
||||
if _, key := s.imageConf(); key == "" {
|
||||
return ""
|
||||
}
|
||||
return "\n\nКартинки. Ты умеешь рисовать и править фото: сам, по смыслу разговора, без просьбы «вызови инструмент» и без лишних вопросов, " +
|
||||
"когда пользователь просит нарисовать, сгенерировать, изобразить, сделать картинку, арт, логотип, обои, иллюстрацию " +
|
||||
"или изменить, дорисовать, убрать, заменить что-то на фото.\n" +
|
||||
"```image\nописание картинки\n``` - создать новую. Опиши сцену, стиль, свет, детали; язык описания любой.\n" +
|
||||
"```image edit\nчто изменить на фото\n``` - править фото пользователя: то, что приложено к его сообщению " +
|
||||
"(приложено несколько - после edit назови файл: ```image edit имя.jpg```); фото нет - последнюю созданную картинку.\n" +
|
||||
"В первой строке блока можно задать формат: 1:1 (по умолчанию), 16:9, 9:16, 3:2, 2:3, 4:3, 3:4, auto - и качество: " +
|
||||
"fast (по умолчанию, 1 кредит), artist (5, лучше), 0.2plus (10, лучшее); повышай качество, только если пользователь просит. " +
|
||||
"Например: ```image 16:9 artist```.\n" +
|
||||
"Сервис по умолчанию сам улучшает описание (дописывает детали и стиль) - это хорошо для короткого запроса. " +
|
||||
"Если пользователь дал готовое, точное описание (промпт целиком, логотип «ровно как сказано», текст на картинке, строгая композиция) " +
|
||||
"или просит ничего не добавлять - добавь noenhance: ```image noenhance 3:2```. Для правки фото это не применяется.\n" +
|
||||
"Картинку покажет сама панель: ссылку в ответ не вставляй, скажи коротко, что получилось, и предложи поправить. " +
|
||||
"Делается она до пары минут. Одно действие за ответ."
|
||||
}
|
||||
|
||||
// imageError says in Russian what an error code of the service means.
|
||||
func imageError(code, msg string) string {
|
||||
switch code {
|
||||
case "unauthorized":
|
||||
return "сервис не принял API-ключ - проверьте его в Настройки › ИИ-ассистент › Генерация изображений"
|
||||
case "banned":
|
||||
return "аккаунт сервиса картинок заблокирован"
|
||||
case "insufficient_credits":
|
||||
return "на аккаунте сервиса картинок не хватает кредитов"
|
||||
case "daily_limit":
|
||||
return "исчерпан дневной лимит картинок"
|
||||
case "job_busy":
|
||||
return "уже делается другая картинка - дождитесь её"
|
||||
case "invalid_prompt":
|
||||
return "сервис не принял описание (пустое или длиннее 4000 символов)"
|
||||
case "invalid_image":
|
||||
return "сервис не принял фото (нужен png, jpg или webp)"
|
||||
case "image_too_large":
|
||||
return "фото больше 20 МБ"
|
||||
case "tokens_exhausted":
|
||||
return "сервис картинок перегружен - попробуйте позже"
|
||||
case "generation_failed", "edit_failed":
|
||||
return "сервис не справился с картинкой (кредиты вернутся)"
|
||||
case "source_missing":
|
||||
return "исходное фото потерялось на сервисе - загрузите его заново"
|
||||
}
|
||||
if msg == "" {
|
||||
msg = "сервис картинок ответил ошибкой"
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
var imageClient = &http.Client{Timeout: 90 * time.Second}
|
||||
|
||||
// imageCall sends the request to the service with the key and reads its
|
||||
// JSON; an error is words for the user.
|
||||
func imageCall(ctx context.Context, req *http.Request, key string, v any) error {
|
||||
req = req.WithContext(ctx)
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := imageClient.Do(req)
|
||||
if err != nil {
|
||||
return errors.New("сервис картинок не отвечает: " + err.Error())
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
var e struct {
|
||||
OK *bool `json:"ok"`
|
||||
ErrorCode string `json:"error_code"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if json.Unmarshal(b, &e) != nil {
|
||||
return errors.New("сервис картинок ответил " + resp.Status)
|
||||
}
|
||||
if (e.OK != nil && !*e.OK) || resp.StatusCode/100 != 2 {
|
||||
return errors.New(imageError(e.ErrorCode, e.Error))
|
||||
}
|
||||
return json.Unmarshal(b, v)
|
||||
}
|
||||
|
||||
var (
|
||||
imageRatios = map[string]bool{"1:1": true, "16:9": true, "9:16": true, "3:2": true, "2:3": true, "4:3": true, "3:4": true, "auto": true}
|
||||
imageModels = map[string]bool{"fast": true, "artist": true, "0.2plus": true}
|
||||
imageJobRe = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
|
||||
imageExt = map[string]string{"image/png": "png", "image/jpeg": "jpg", "image/jpg": "jpg", "image/webp": "webp"}
|
||||
)
|
||||
|
||||
// POST /api/panel/ai/image - {mode: generate|edit, prompt, model, ratio,
|
||||
// image: {mime, data (base64)} | image_url}: puts the job, answers its id.
|
||||
func (s *Server) handleAIImage(w http.ResponseWriter, r *http.Request) {
|
||||
fail := func(msg string) { writeJSON(w, map[string]any{"ok": false, "error": msg}) }
|
||||
if !s.aiOn(s.currentAdmin(r).ID) {
|
||||
fail("ассистент выключен в профиле")
|
||||
return
|
||||
}
|
||||
base, key := s.imageConf()
|
||||
if key == "" {
|
||||
fail("не задан API-ключ сервиса картинок (Настройки › ИИ-ассистент › Генерация изображений)")
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
Mode string `json:"mode"`
|
||||
Prompt string `json:"prompt"`
|
||||
Model string `json:"model"`
|
||||
Ratio string `json:"ratio"`
|
||||
Enhance *bool `json:"enhance"` // the service improves the description; on unless false
|
||||
ImageURL string `json:"image_url"`
|
||||
Image struct {
|
||||
MIME string `json:"mime"`
|
||||
Data string `json:"data"`
|
||||
} `json:"image"`
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 32<<20)).Decode(&in); err != nil {
|
||||
fail("плохой запрос")
|
||||
return
|
||||
}
|
||||
in.Prompt = strings.TrimSpace(in.Prompt)
|
||||
if in.Prompt == "" {
|
||||
fail("пустое описание картинки")
|
||||
return
|
||||
}
|
||||
if r := []rune(in.Prompt); len(r) > 4000 {
|
||||
in.Prompt = string(r[:4000])
|
||||
}
|
||||
var job struct {
|
||||
JobID string `json:"job_id"`
|
||||
Cost int `json:"cost"`
|
||||
}
|
||||
ctx := r.Context()
|
||||
if in.Mode != "edit" {
|
||||
if !imageModels[in.Model] {
|
||||
in.Model = "fast"
|
||||
}
|
||||
if !imageRatios[in.Ratio] {
|
||||
in.Ratio = "1:1"
|
||||
}
|
||||
enhance := in.Enhance == nil || *in.Enhance
|
||||
b, _ := json.Marshal(map[string]any{"prompt": in.Prompt, "model": in.Model, "ratio": in.Ratio, "enhance": enhance})
|
||||
req, _ := http.NewRequest(http.MethodPost, base+"/api/vertex/v1/generate", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if err := imageCall(ctx, req, key, &job); err != nil {
|
||||
fail(err.Error())
|
||||
return
|
||||
}
|
||||
} else {
|
||||
mime, data, err := s.imageSource(ctx, base, in.Image.MIME, in.Image.Data, in.ImageURL)
|
||||
if err != nil {
|
||||
fail(err.Error())
|
||||
return
|
||||
}
|
||||
var body bytes.Buffer
|
||||
mw := multipart.NewWriter(&body)
|
||||
h := textproto.MIMEHeader{}
|
||||
h.Set("Content-Disposition", `form-data; name="image"; filename="photo.`+imageExt[mime]+`"`)
|
||||
h.Set("Content-Type", mime)
|
||||
part, _ := mw.CreatePart(h)
|
||||
_, _ = part.Write(data)
|
||||
_ = mw.WriteField("prompt", in.Prompt)
|
||||
_ = mw.Close()
|
||||
req, _ := http.NewRequest(http.MethodPost, base+"/api/vertex/v1/edit", &body)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
if err := imageCall(ctx, req, key, &job); err != nil {
|
||||
fail(err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
if job.JobID == "" {
|
||||
fail("сервис картинок не дал номер задачи")
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"ok": true, "job_id": job.JobID, "cost": job.Cost})
|
||||
}
|
||||
|
||||
// imageSource is the photo to edit: sent by the browser, or a picture made
|
||||
// before, taken from the service's own address (and from no other).
|
||||
func (s *Server) imageSource(ctx context.Context, base, mime, b64, picURL string) (string, []byte, error) {
|
||||
if picURL != "" {
|
||||
u, err := url.Parse(picURL)
|
||||
bu, _ := url.Parse(base)
|
||||
if err != nil || bu == nil || u.Host != bu.Host || (u.Scheme != "https" && u.Scheme != "http") {
|
||||
return "", nil, errors.New("прежнюю картинку можно править только с сервиса картинок")
|
||||
}
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||
resp, err := imageClient.Do(req)
|
||||
if err != nil || resp.StatusCode/100 != 2 {
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
return "", nil, errors.New("прежняя картинка больше не открывается")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, imageMaxBytes+1))
|
||||
mime = strings.ToLower(strings.SplitN(resp.Header.Get("Content-Type"), ";", 2)[0])
|
||||
if imageExt[mime] == "" {
|
||||
mime = "image/png"
|
||||
}
|
||||
return mime, data, nil
|
||||
}
|
||||
mime = strings.ToLower(mime)
|
||||
if imageExt[mime] == "" {
|
||||
return "", nil, errors.New("править можно png, jpg и webp")
|
||||
}
|
||||
data, err := base64.StdEncoding.DecodeString(b64)
|
||||
if err != nil || len(data) == 0 {
|
||||
return "", nil, errors.New("нет фото для правки: прикрепите его к сообщению")
|
||||
}
|
||||
if len(data) > imageMaxBytes {
|
||||
return "", nil, errors.New("фото больше 20 МБ")
|
||||
}
|
||||
return mime, data, nil
|
||||
}
|
||||
|
||||
// GET /api/panel/ai/image/{job}?wait=1..45 - the job's state, waiting up to
|
||||
// wait seconds for it to end.
|
||||
func (s *Server) handleAIImageJob(w http.ResponseWriter, r *http.Request) {
|
||||
fail := func(msg string) { writeJSON(w, map[string]any{"ok": false, "error": msg}) }
|
||||
if !s.aiOn(s.currentAdmin(r).ID) {
|
||||
fail("ассистент выключен в профиле")
|
||||
return
|
||||
}
|
||||
base, key := s.imageConf()
|
||||
id := r.PathValue("job")
|
||||
if key == "" || !imageJobRe.MatchString(id) {
|
||||
fail("нет такой задачи")
|
||||
return
|
||||
}
|
||||
wait, _ := strconv.Atoi(r.URL.Query().Get("wait"))
|
||||
if wait < 0 {
|
||||
wait = 0
|
||||
}
|
||||
if wait > 45 {
|
||||
wait = 45
|
||||
}
|
||||
req, _ := http.NewRequest(http.MethodGet, base+"/api/vertex/v1/jobs/"+id+"?wait="+strconv.Itoa(wait), nil)
|
||||
var st struct {
|
||||
Status string `json:"status"`
|
||||
PhotoURL string `json:"photo_url"`
|
||||
Cost int `json:"cost"`
|
||||
Balance int `json:"balance"`
|
||||
ErrorCode string `json:"error_code"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := imageCall(r.Context(), req, key, &st); err != nil {
|
||||
fail(err.Error())
|
||||
return
|
||||
}
|
||||
out := map[string]any{"ok": true, "status": st.Status}
|
||||
switch st.Status {
|
||||
case "done":
|
||||
out["photo_url"], out["cost"], out["balance"] = st.PhotoURL, st.Cost, st.Balance
|
||||
case "failed":
|
||||
out["error"] = imageError(st.ErrorCode, st.Error)
|
||||
}
|
||||
writeJSON(w, out)
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeVertex is the image service: it records what it was asked and answers
|
||||
// like the real one (a job to poll, a picture when done).
|
||||
type fakeVertex struct {
|
||||
srv *httptest.Server
|
||||
mu sync.Mutex
|
||||
auth []string
|
||||
gen map[string]any
|
||||
edit struct {
|
||||
prompt string
|
||||
image []byte
|
||||
name string
|
||||
}
|
||||
polls int
|
||||
failAs string // an error_code the job ends with
|
||||
}
|
||||
|
||||
func newFakeVertex(t *testing.T) *fakeVertex {
|
||||
f := &fakeVertex{}
|
||||
f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.auth = append(f.auth, r.Header.Get("Authorization"))
|
||||
// The pictures are open by their address, as at the real service.
|
||||
if r.URL.Path == "/uploads/userphoto/ai/t.png" {
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
_, _ = w.Write([]byte("\x89PNG-original"))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if r.Header.Get("Authorization") != "Bearer vx_good" {
|
||||
w.WriteHeader(401)
|
||||
fmt.Fprint(w, `{"ok":false,"error_code":"unauthorized","error":"Неверный ключ"}`)
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case r.URL.Path == "/api/vertex/v1/generate":
|
||||
_ = json.NewDecoder(r.Body).Decode(&f.gen)
|
||||
w.WriteHeader(202)
|
||||
fmt.Fprint(w, `{"ok":true,"job_id":"job_gen1","status":"queued","cost":5}`)
|
||||
case r.URL.Path == "/api/vertex/v1/edit":
|
||||
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||||
w.WriteHeader(400)
|
||||
fmt.Fprint(w, `{"ok":false,"error_code":"invalid_image","error":"нет фото"}`)
|
||||
return
|
||||
}
|
||||
f.edit.prompt = r.FormValue("prompt")
|
||||
file, hdr, err := r.FormFile("image")
|
||||
if err == nil {
|
||||
f.edit.image, _ = io.ReadAll(file)
|
||||
f.edit.name = hdr.Filename
|
||||
}
|
||||
w.WriteHeader(202)
|
||||
fmt.Fprint(w, `{"ok":true,"job_id":"job_edit1","status":"queued","cost":2}`)
|
||||
case strings.HasPrefix(r.URL.Path, "/api/vertex/v1/jobs/"):
|
||||
f.polls++
|
||||
if f.failAs != "" {
|
||||
fmt.Fprintf(w, `{"ok":true,"job_id":"x","status":"failed","error_code":%q,"error":"сбой"}`, f.failAs)
|
||||
return
|
||||
}
|
||||
if f.polls < 2 {
|
||||
fmt.Fprint(w, `{"ok":true,"job_id":"x","status":"running"}`)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, `{"ok":true,"job_id":"x","status":"done","photo_url":"%s/uploads/userphoto/ai/t.png","cost":5,"balance":42}`, f.srv.URL)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(f.srv.Close)
|
||||
return f
|
||||
}
|
||||
|
||||
// setImage points the panel at the fake service with a key.
|
||||
func setImage(t *testing.T, s *Server, f *fakeVertex, key string) {
|
||||
t.Helper()
|
||||
_ = s.db.SetConfig(cfgImageURL, f.srv.URL)
|
||||
_ = s.db.SetConfig(cfgImageKey, key)
|
||||
}
|
||||
|
||||
func apiCall(t *testing.T, s *Server, c *http.Cookie, method, path, body string) string {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
req.AddCookie(c)
|
||||
w := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(w, req)
|
||||
return w.Body.String()
|
||||
}
|
||||
|
||||
// A picture from nothing: the prompt, model and ratio go to the service with
|
||||
// the key; the job's progress and its picture come back, the key never.
|
||||
func TestImageGenerate(t *testing.T) {
|
||||
s, c := newTestServer(t)
|
||||
f := newFakeVertex(t)
|
||||
setImage(t, s, f, "vx_good")
|
||||
got := apiCall(t, s, c, "POST", "/api/panel/ai/image", `{"mode":"generate","prompt":"cat in space","model":"artist","ratio":"3:4"}`)
|
||||
if !strings.Contains(got, `"ok":true`) || !strings.Contains(got, `"job_id":"job_gen1"`) || strings.Contains(got, "vx_good") {
|
||||
t.Fatalf("create: %s", got)
|
||||
}
|
||||
if f.gen["prompt"] != "cat in space" || f.gen["model"] != "artist" || f.gen["ratio"] != "3:4" || f.gen["enhance"] != true {
|
||||
t.Fatalf("what the service got: %v", f.gen)
|
||||
}
|
||||
if got := apiCall(t, s, c, "GET", "/api/panel/ai/image/job_gen1?wait=1", ""); !strings.Contains(got, `"status":"running"`) {
|
||||
t.Fatalf("first poll: %s", got)
|
||||
}
|
||||
got = apiCall(t, s, c, "GET", "/api/panel/ai/image/job_gen1?wait=1", "")
|
||||
if !strings.Contains(got, `"status":"done"`) || !strings.Contains(got, "/uploads/userphoto/ai/t.png") || !strings.Contains(got, `"balance":42`) || strings.Contains(got, "vx_good") {
|
||||
t.Fatalf("done: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown models and ratios are not sent on: the defaults go instead.
|
||||
func TestImageGenerateDefaults(t *testing.T) {
|
||||
s, c := newTestServer(t)
|
||||
f := newFakeVertex(t)
|
||||
setImage(t, s, f, "vx_good")
|
||||
apiCall(t, s, c, "POST", "/api/panel/ai/image", `{"mode":"generate","prompt":"x","model":"gpt","ratio":"7:1"}`)
|
||||
if f.gen["model"] != "fast" || f.gen["ratio"] != "1:1" {
|
||||
t.Fatalf("defaults: %v", f.gen)
|
||||
}
|
||||
}
|
||||
|
||||
// Editing sends the user's photo as a file with the instruction.
|
||||
func TestImageEditFromAttachedPhoto(t *testing.T) {
|
||||
s, c := newTestServer(t)
|
||||
f := newFakeVertex(t)
|
||||
setImage(t, s, f, "vx_good")
|
||||
photo := []byte("\xff\xd8\xffJPEGDATA")
|
||||
b, _ := json.Marshal(map[string]any{"mode": "edit", "prompt": "сделай закат",
|
||||
"image": map[string]string{"mime": "image/jpeg", "data": base64.StdEncoding.EncodeToString(photo)}})
|
||||
got := apiCall(t, s, c, "POST", "/api/panel/ai/image", string(b))
|
||||
if !strings.Contains(got, `"job_id":"job_edit1"`) {
|
||||
t.Fatalf("edit: %s", got)
|
||||
}
|
||||
if f.edit.prompt != "сделай закат" || !bytes.Equal(f.edit.image, photo) || !strings.HasSuffix(f.edit.name, ".jpg") {
|
||||
t.Fatalf("what the service got: %q %q %q", f.edit.prompt, f.edit.image, f.edit.name)
|
||||
}
|
||||
// A format the service does not take is told so, not sent.
|
||||
b, _ = json.Marshal(map[string]any{"mode": "edit", "prompt": "x", "image": map[string]string{"mime": "image/gif", "data": "R0lG"}})
|
||||
if got := apiCall(t, s, c, "POST", "/api/panel/ai/image", string(b)); !strings.Contains(got, `"ok":false`) || !strings.Contains(got, "png") {
|
||||
t.Fatalf("gif: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A picture made before can be edited again: fetched from the service's own
|
||||
// address only - never from another.
|
||||
func TestImageEditFromEarlierPicture(t *testing.T) {
|
||||
s, c := newTestServer(t)
|
||||
f := newFakeVertex(t)
|
||||
setImage(t, s, f, "vx_good")
|
||||
b, _ := json.Marshal(map[string]any{"mode": "edit", "prompt": "темнее", "image_url": f.srv.URL + "/uploads/userphoto/ai/t.png"})
|
||||
if got := apiCall(t, s, c, "POST", "/api/panel/ai/image", string(b)); !strings.Contains(got, `"ok":true`) {
|
||||
t.Fatalf("by url: %s", got)
|
||||
}
|
||||
if !bytes.Equal(f.edit.image, []byte("\x89PNG-original")) || !strings.HasSuffix(f.edit.name, ".png") {
|
||||
t.Fatalf("the picture sent: %q %q", f.edit.image, f.edit.name)
|
||||
}
|
||||
b, _ = json.Marshal(map[string]any{"mode": "edit", "prompt": "x", "image_url": "http://127.0.0.1:9/secret.png"})
|
||||
if got := apiCall(t, s, c, "POST", "/api/panel/ai/image", string(b)); !strings.Contains(got, `"ok":false`) {
|
||||
t.Fatalf("a foreign address was fetched: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// What goes wrong is told in words: no key, a wrong key, the service's own
|
||||
// codes, a job that failed.
|
||||
func TestImageErrors(t *testing.T) {
|
||||
s, c := newTestServer(t)
|
||||
f := newFakeVertex(t)
|
||||
if got := apiCall(t, s, c, "POST", "/api/panel/ai/image", `{"mode":"generate","prompt":"x"}`); !strings.Contains(got, "ключ") {
|
||||
t.Fatalf("no key: %s", got)
|
||||
}
|
||||
setImage(t, s, f, "vx_bad")
|
||||
if got := apiCall(t, s, c, "POST", "/api/panel/ai/image", `{"mode":"generate","prompt":"x"}`); !strings.Contains(got, `"ok":false`) || !strings.Contains(got, "ключ") {
|
||||
t.Fatalf("wrong key: %s", got)
|
||||
}
|
||||
setImage(t, s, f, "vx_good")
|
||||
f.failAs = "insufficient_credits"
|
||||
if got := apiCall(t, s, c, "GET", "/api/panel/ai/image/job_gen1", ""); !strings.Contains(got, `"status":"failed"`) || !strings.Contains(got, "кредит") {
|
||||
t.Fatalf("failed job: %s", got)
|
||||
}
|
||||
if got := apiCall(t, s, c, "GET", "/api/panel/ai/image/..%2Fme", ""); !strings.Contains(got, `"ok":false`) {
|
||||
t.Fatalf("a job id out of shape: %s", got)
|
||||
}
|
||||
if got := apiCall(t, s, c, "POST", "/api/panel/ai/image", `{"mode":"generate","prompt":""}`); !strings.Contains(got, `"ok":false`) {
|
||||
t.Fatalf("empty prompt: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The assistant off in the profile means no pictures either.
|
||||
func TestImageNeedsAssistantOn(t *testing.T) {
|
||||
s, c := newTestServer(t)
|
||||
f := newFakeVertex(t)
|
||||
setImage(t, s, f, "vx_good")
|
||||
a, _ := s.db.AdminByUsername("adm")
|
||||
_ = s.db.SetConfig(aiUserKey(a.ID), "0")
|
||||
if got := apiCall(t, s, c, "POST", "/api/panel/ai/image", `{"mode":"generate","prompt":"x"}`); !strings.Contains(got, "выключен") {
|
||||
t.Fatalf("off: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The key is saved from Настройки, shown back to the admin, and the chat
|
||||
// learns only that pictures are on.
|
||||
func TestImageKeySetting(t *testing.T) {
|
||||
s, c := newTestServer(t)
|
||||
postAIModels(t, s, c, [][7]string{{"", "M", "gradio", "", "", "", ""}})
|
||||
f := url.Values{"csrf": {"tok"}, "m_type": {"gradio"}, "ai_image_key": {"vx_secret"}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/settings/ai", strings.NewReader(f.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(c)
|
||||
req.AddCookie(&http.Cookie{Name: csrfCookie, Value: "tok"})
|
||||
s.Handler().ServeHTTP(httptest.NewRecorder(), req)
|
||||
if _, k := s.imageConf(); k != "vx_secret" {
|
||||
t.Fatalf("key not saved: %q", k)
|
||||
}
|
||||
if body := get(t, s, c, "/settings?frag=1").Body.String(); !strings.Contains(body, `value="vx_secret"`) {
|
||||
t.Fatal("the settings page does not show the key")
|
||||
}
|
||||
state := get(t, s, c, "/api/panel/ai").Body.String()
|
||||
if strings.Contains(state, "vx_secret") || !strings.Contains(state, `"image":true`) {
|
||||
t.Fatalf("state: %s", state)
|
||||
}
|
||||
}
|
||||
|
||||
// With a key the model is told it can draw; without, it is not.
|
||||
func TestImagePromptOnlyWithKey(t *testing.T) {
|
||||
s, _ := newTestServer(t)
|
||||
if strings.Contains(s.imagePrompt(), "```image") {
|
||||
t.Fatal("told about pictures with no key")
|
||||
}
|
||||
_ = s.db.SetConfig(cfgImageKey, "vx_good")
|
||||
p := s.imagePrompt()
|
||||
for _, want := range []string{"```image\n", "```image edit", "fast", "artist", "0.2plus", "16:9"} {
|
||||
if !strings.Contains(p, want) {
|
||||
t.Fatalf("the prompt lacks %q:\n%s", want, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The chat gives the model the image instructions when pictures are on.
|
||||
func TestAIChatMentionsImageTool(t *testing.T) {
|
||||
s, c := newTestServer(t)
|
||||
var system string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var b map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&b)
|
||||
system, _ = b["messages"].([]any)[0].(map[string]any)["content"].(string)
|
||||
fmt.Fprint(w, openAIChunk("content", "ok")+"data: [DONE]\n\n")
|
||||
}))
|
||||
defer srv.Close()
|
||||
postAIModels(t, s, c, [][7]string{{"", "M", "openai", srv.URL, "m", "", ""}})
|
||||
ask := func() {
|
||||
apiCall(t, s, c, "POST", "/api/panel/ai/chat", `{"message":"нарисуй кота","context":"x"}`)
|
||||
}
|
||||
ask()
|
||||
if strings.Contains(system, "```image") {
|
||||
t.Fatal("image tool offered with no key")
|
||||
}
|
||||
_ = s.db.SetConfig(cfgImageKey, "vx_good")
|
||||
ask()
|
||||
if !strings.Contains(system, "```image") {
|
||||
t.Fatalf("image tool not offered with a key:\n%s", system)
|
||||
}
|
||||
}
|
||||
|
||||
// The description is improved by the service unless the model asks for it
|
||||
// as it is.
|
||||
func TestImageEnhanceSwitch(t *testing.T) {
|
||||
s, c := newTestServer(t)
|
||||
f := newFakeVertex(t)
|
||||
setImage(t, s, f, "vx_good")
|
||||
apiCall(t, s, c, "POST", "/api/panel/ai/image", `{"mode":"generate","prompt":"x"}`)
|
||||
if f.gen["enhance"] != true {
|
||||
t.Fatalf("by default: %v", f.gen)
|
||||
}
|
||||
apiCall(t, s, c, "POST", "/api/panel/ai/image", `{"mode":"generate","prompt":"x","enhance":false}`)
|
||||
if f.gen["enhance"] != false {
|
||||
t.Fatalf("switched off: %v", f.gen)
|
||||
}
|
||||
apiCall(t, s, c, "POST", "/api/panel/ai/image", `{"mode":"generate","prompt":"x","enhance":true}`)
|
||||
if f.gen["enhance"] != true {
|
||||
t.Fatalf("switched on: %v", f.gen)
|
||||
}
|
||||
{
|
||||
p := s.imagePrompt()
|
||||
_ = s.db.SetConfig(cfgImageKey, "vx_good")
|
||||
if !strings.Contains(s.imagePrompt(), "noenhance") {
|
||||
t.Fatalf("the model is not told of the switch:\n%s", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,80 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 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;
|
||||
// and the assistant's model: which one, and whether the chat always offers
|
||||
// the choice.
|
||||
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
|
||||
// AIModelPick: the model menu in the chat - when there are several
|
||||
// (default) or always. AIModel: the id of the model chosen.
|
||||
AIModelPick string `json:"aiModelPick,omitempty"`
|
||||
AIModel string `json:"aiModel,omitempty"`
|
||||
}
|
||||
|
||||
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 = ""
|
||||
}
|
||||
if p.AIModelPick != "always" {
|
||||
p.AIModelPick = ""
|
||||
}
|
||||
if len(p.AIModel) > 32 || strings.Trim(p.AIModel, "0123456789abcdefghijklmnopqrstuvwxyz") != "" {
|
||||
p.AIModel = ""
|
||||
}
|
||||
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 !s.aiOn(a.ID) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
+167
-16
@@ -1,7 +1,9 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"embed"
|
||||
"encoding/hex"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"log"
|
||||
@@ -56,6 +58,13 @@ type Server struct {
|
||||
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.
|
||||
@@ -63,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
|
||||
}
|
||||
@@ -89,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
|
||||
@@ -123,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
|
||||
@@ -183,7 +207,7 @@ func (s *Server) Handler() http.Handler {
|
||||
|
||||
// Static assets (xterm.js etc.) from the embedded FS.
|
||||
staticFS, _ := fs.Sub(webFS, "web/static")
|
||||
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
|
||||
mux.Handle("GET /static/", http.StripPrefix("/static/", staticHandler(staticFS, s.assets)))
|
||||
|
||||
// Public: install script + agent binary downloads.
|
||||
mux.HandleFunc("GET /install.sh", s.handleInstallScript)
|
||||
@@ -198,38 +222,88 @@ 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 /api/panel/ai/image", s.requireAdmin(s.handleAIImage))
|
||||
mux.Handle("GET /api/panel/ai/image/{job}", s.requireAdmin(s.handleAIImageJob))
|
||||
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).
|
||||
mux.Handle("GET /api/panel/agents/{id}/fs/download", s.requireAdmin(s.handleFSDownload))
|
||||
mux.Handle("POST /api/panel/agents/{id}/fs/upload", s.requireAdmin(s.handleFSUploadStart))
|
||||
mux.Handle("PUT /api/panel/fs/xfer/{xid}/{i}", s.requireAdmin(s.handleXferPut))
|
||||
mux.Handle("POST /api/panel/fs/xfer/{xid}/cancel", s.requireAdmin(s.handleXferCancel))
|
||||
mux.Handle("GET /api/panel/fs/xfer/{xid}/result", s.requireAdmin(s.handleXferResult))
|
||||
mux.Handle("POST /api/panel/agents/{id}/fs/ship", s.requireAdmin(s.handleFSShip))
|
||||
mux.Handle("GET /api/panel/fs/job/{jid}", s.requireAdmin(s.handleShipJob))
|
||||
mux.Handle("POST /api/panel/fs/job/{jid}/cancel", s.requireAdmin(s.handleShipCancel))
|
||||
mux.Handle("POST /api/panel/agents/{id}/fs/{op}", s.requireAdmin(s.handleFSOp))
|
||||
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))
|
||||
@@ -248,6 +322,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)
|
||||
@@ -257,6 +332,11 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("POST /api/agent/speed/up", s.handleSpeedUp)
|
||||
mux.HandleFunc("GET /api/agent/speed/relay/down", s.handleSpeedRelayDown)
|
||||
mux.HandleFunc("POST /api/agent/speed/relay/up", s.handleSpeedRelayUp)
|
||||
mux.HandleFunc("GET /api/agent/fs/xfer/{xid}/{i}", s.handleAgentXferGet)
|
||||
mux.HandleFunc("POST /api/agent/fs/xfer/{xid}/{i}", s.handleAgentXferPost)
|
||||
mux.HandleFunc("POST /api/agent/fs/job/{jid}/progress", s.handleShipProgress)
|
||||
mux.HandleFunc("POST /api/agent/fs/relay/{rid}/{i}", s.handleRelayPost)
|
||||
mux.HandleFunc("GET /api/agent/fs/relay/{rid}/{i}", s.handleRelayGet)
|
||||
|
||||
return s.logRequests(mux)
|
||||
}
|
||||
@@ -273,6 +353,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)
|
||||
})
|
||||
}
|
||||
@@ -326,3 +436,44 @@ func clientIP(r *http.Request) string {
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// assetHashes hashes every static file, for asset URLs and ETags.
|
||||
func assetHashes(fsys fs.FS) map[string]string {
|
||||
out := map[string]string{}
|
||||
_ = fs.WalkDir(fsys, ".", func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if data, err := fs.ReadFile(fsys, p); err == nil {
|
||||
sum := sha256.Sum256(data)
|
||||
out[p] = hex.EncodeToString(sum[:8])
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// staticHandler serves the embedded assets. Pages ask for them by URLs that
|
||||
// carry the content hash (?v=): such a file never changes and is kept for a
|
||||
// year, by the browser and by a proxy in front such as Cloudflare. Asked for
|
||||
// without it, a file is checked every time against its ETag.
|
||||
func staticHandler(fsys fs.FS, hashes map[string]string) http.Handler {
|
||||
files := http.FileServer(http.FS(fsys))
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// The JS tests sit beside the scripts but are not served.
|
||||
if strings.HasSuffix(r.URL.Path, ".test.js") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
h := hashes[strings.TrimPrefix(r.URL.Path, "/")]
|
||||
if h != "" {
|
||||
w.Header().Set("ETag", `"`+h+`"`)
|
||||
}
|
||||
if v := r.URL.Query().Get("v"); h != "" && v == h {
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
} else {
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
}
|
||||
files.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,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,239 @@
|
||||
// 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="aiModelPick"><option value="">когда моделей несколько</option><option value="always">всегда</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(); });
|
||||
};
|
||||
})();
|
||||
File diff suppressed because it is too large.
Load diff
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,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (C) 2017 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
@@ -0,0 +1 @@
|
||||
.CodeMirror{font-family:monospace;height:300px;color:#000;direction:ltr}.CodeMirror-lines{padding:4px 0}.CodeMirror pre.CodeMirror-line,.CodeMirror pre.CodeMirror-line-like{padding:0 4px}.CodeMirror-gutter-filler,.CodeMirror-scrollbar-filler{background-color:#fff}.CodeMirror-gutters{border-right:1px solid #ddd;background-color:#f7f7f7;white-space:nowrap}.CodeMirror-linenumber{padding:0 3px 0 5px;min-width:20px;text-align:right;color:#999;white-space:nowrap}.CodeMirror-guttermarker{color:#000}.CodeMirror-guttermarker-subtle{color:#999}.CodeMirror-cursor{border-left:1px solid #000;border-right:none;width:0}.CodeMirror div.CodeMirror-secondarycursor{border-left:1px solid silver}.cm-fat-cursor .CodeMirror-cursor{width:auto;border:0!important;background:#7e7}.cm-fat-cursor div.CodeMirror-cursors{z-index:1}.cm-fat-cursor .CodeMirror-line::selection,.cm-fat-cursor .CodeMirror-line>span::selection,.cm-fat-cursor .CodeMirror-line>span>span::selection{background:0 0}.cm-fat-cursor .CodeMirror-line::-moz-selection,.cm-fat-cursor .CodeMirror-line>span::-moz-selection,.cm-fat-cursor .CodeMirror-line>span>span::-moz-selection{background:0 0}.cm-fat-cursor{caret-color:transparent}@-moz-keyframes blink{50%{background-color:transparent}}@-webkit-keyframes blink{50%{background-color:transparent}}@keyframes blink{50%{background-color:transparent}}.cm-tab{display:inline-block;text-decoration:inherit}.CodeMirror-rulers{position:absolute;left:0;right:0;top:-50px;bottom:0;overflow:hidden}.CodeMirror-ruler{border-left:1px solid #ccc;top:0;bottom:0;position:absolute}.cm-s-default .cm-header{color:#00f}.cm-s-default .cm-quote{color:#090}.cm-negative{color:#d44}.cm-positive{color:#292}.cm-header,.cm-strong{font-weight:700}.cm-em{font-style:italic}.cm-link{text-decoration:underline}.cm-strikethrough{text-decoration:line-through}.cm-s-default .cm-keyword{color:#708}.cm-s-default .cm-atom{color:#219}.cm-s-default .cm-number{color:#164}.cm-s-default .cm-def{color:#00f}.cm-s-default .cm-variable-2{color:#05a}.cm-s-default .cm-type,.cm-s-default .cm-variable-3{color:#085}.cm-s-default .cm-comment{color:#a50}.cm-s-default .cm-string{color:#a11}.cm-s-default .cm-string-2{color:#f50}.cm-s-default .cm-meta{color:#555}.cm-s-default .cm-qualifier{color:#555}.cm-s-default .cm-builtin{color:#30a}.cm-s-default .cm-bracket{color:#997}.cm-s-default .cm-tag{color:#170}.cm-s-default .cm-attribute{color:#00c}.cm-s-default .cm-hr{color:#999}.cm-s-default .cm-link{color:#00c}.cm-s-default .cm-error{color:red}.cm-invalidchar{color:red}.CodeMirror-composing{border-bottom:2px solid}div.CodeMirror span.CodeMirror-matchingbracket{color:#0b0}div.CodeMirror span.CodeMirror-nonmatchingbracket{color:#a22}.CodeMirror-matchingtag{background:rgba(255,150,0,.3)}.CodeMirror-activeline-background{background:#e8f2ff}.CodeMirror{position:relative;overflow:hidden;background:#fff}.CodeMirror-scroll{overflow:scroll!important;margin-bottom:-50px;margin-right:-50px;padding-bottom:50px;height:100%;outline:0;position:relative;z-index:0}.CodeMirror-sizer{position:relative;border-right:50px solid transparent}.CodeMirror-gutter-filler,.CodeMirror-hscrollbar,.CodeMirror-scrollbar-filler,.CodeMirror-vscrollbar{position:absolute;z-index:6;display:none;outline:0}.CodeMirror-vscrollbar{right:0;top:0;overflow-x:hidden;overflow-y:scroll}.CodeMirror-hscrollbar{bottom:0;left:0;overflow-y:hidden;overflow-x:scroll}.CodeMirror-scrollbar-filler{right:0;bottom:0}.CodeMirror-gutter-filler{left:0;bottom:0}.CodeMirror-gutters{position:absolute;left:0;top:0;min-height:100%;z-index:3}.CodeMirror-gutter{white-space:normal;height:100%;display:inline-block;vertical-align:top;margin-bottom:-50px}.CodeMirror-gutter-wrapper{position:absolute;z-index:4;background:0 0!important;border:none!important}.CodeMirror-gutter-background{position:absolute;top:0;bottom:0;z-index:4}.CodeMirror-gutter-elt{position:absolute;cursor:default;z-index:4}.CodeMirror-gutter-wrapper ::selection{background-color:transparent}.CodeMirror-gutter-wrapper ::-moz-selection{background-color:transparent}.CodeMirror-lines{cursor:text;min-height:1px}.CodeMirror pre.CodeMirror-line,.CodeMirror pre.CodeMirror-line-like{-moz-border-radius:0;-webkit-border-radius:0;border-radius:0;border-width:0;background:0 0;font-family:inherit;font-size:inherit;margin:0;white-space:pre;word-wrap:normal;line-height:inherit;color:inherit;z-index:2;position:relative;overflow:visible;-webkit-tap-highlight-color:transparent;-webkit-font-variant-ligatures:contextual;font-variant-ligatures:contextual}.CodeMirror-wrap pre.CodeMirror-line,.CodeMirror-wrap pre.CodeMirror-line-like{word-wrap:break-word;white-space:pre-wrap;word-break:normal}.CodeMirror-linebackground{position:absolute;left:0;right:0;top:0;bottom:0;z-index:0}.CodeMirror-linewidget{position:relative;z-index:2;padding:.1px}.CodeMirror-rtl pre{direction:rtl}.CodeMirror-code{outline:0}.CodeMirror-gutter,.CodeMirror-gutters,.CodeMirror-linenumber,.CodeMirror-scroll,.CodeMirror-sizer{-moz-box-sizing:contentLine truncated
|
||||
@@ -0,0 +1,27 @@
|
||||
/* CodeMirror 5.65.16 (MIT, see LICENSE) with the addons and language modes the file editor uses, joined into one file. */
|
||||
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e=e||self).CodeMirror=t()}(this,function(){"use strict";var e=navigator.userAgent,l=navigator.platform,d=/gecko\/\d/i.test(e),s=/MSIE \d/.test(e),a=/Trident\/(?:[7-9]|\d{2,})\..*rv:(\d+)/.exec(e),u=/Edge\/(\d+)/.exec(e),w=s||a||u,v=w&&(s?document.documentMode||6:+(u||a)[1]),x=!u&&/WebKit\//.test(e),s=x&&/Qt\/\d+\.\d+/.test(e),m=!u&&/Chrome\/(\d+)/.exec(e),V=m&&+m[1],K=/Opera\//.test(e),j=/Apple Computer/.test(navigator.vendor),c=/Mac OS X 1\d\D([8-9]|\d\d)\D/.test(e),X=/PhantomJS/.test(e),Y=j&&(/Mobile\/\w+/.test(e)||2<navigator.maxTouchPoints),$=/Android/.test(e),_=Y||$||/webOS|BlackBerry|Opera Mini|Opera Mobi|IEMobile/i.test(e),C=Y||/Mac/.test(l),q=/\bCrOS\b/.test(e),a=/win/i.test(l),u=K&&e.match(/Version\/(\d*\.\d*)/),Z=((u=u&&Number(u[1]))&&15<=u&&(x=!(K=!1)),C&&(s||K&&(null==u||u<12.11))),Q=d||w&&9<=v;function J(e){return new RegExp("(^|\\s)"+e+"(?:$|\\s)\\s*")}var ee=function(e,t){var n,r=e.className,t=J(t).exec(r);t&&(n=r.slice(t.index+t[0].length),e.className=r.slice(0,t.index)+(n?t[1]+n:""))};function te(e){for(var t=e.childNodes.length;0<t;--t)e.removeChild(e.firstChild);return e}function y(e,t){return te(e).appendChild(t)}function M(e,t,n,r){var i=document.createElement(e);if(n&&(i.className=n),r&&(i.style.cssText=r),"string"==typeof t)i.appendChild(document.createTextNode(t));else if(t)for(var o=0;o<t.length;++o)i.appendChild(t[o]);return i}function ne(e,t,n,r){e=M(e,t,n,r);return e.setAttribute("role","presentation"),e}function re(e,t){if(3==t.nodeType&&(t=t.parentNode),e.contains)return e.contains(t);do{if((t=11==t.nodeType?t.host:t)==e)return!0}while(t=t.parentNode)}function N(e){var t,n=e.ownerDocument||e;try{t=e.activeElement}catch(e){t=n.body||null}for(;t&&t.shadowRoot&&t.shadowRoot.activeElement;)t=t.shadowRoot.activeElement;return t}function ie(e,t){var n=e.className;J(t).test(n)||(e.className+=(n?" ":"")+t)}function oe(e,t){for(var n=e.split(" "),r=0;r<n.length;r++)n[r]&&!J(n[r]).test(t)&&(t+=" "+n[r]);return t}var le=document.createRange?function(e,t,n,r){var i=document.createRange();return i.setEnd(r||e,n),i.setStart(e,t),i}:function(e,t,n){var r=document.body.createTextRange();try{r.moveToElementText(e.parentNode)}catch(e){return r}return r.collapse(!0),r.moveEnd("character",n),r.moveStart("character",t),r},se=function(e){e.select()};function ae(e){return e.display.wrapper.ownerDocument}function ue(e){return ce(e.display.wrapper)}function ce(e){return e.getRootNode?e.getRootNode():e.ownerDocument}function he(e){return ae(e).defaultView}function de(e){var t=Array.prototype.slice.call(arguments,1);return function(){return e.apply(null,t)}}function fe(e,t,n){for(var r in t=t||{},e)!e.hasOwnProperty(r)||!1===n&&t.hasOwnProperty(r)||(t[r]=e[r]);return t}function S(e,t,n,r,i){null==t&&-1==(t=e.search(/[^\s\u00a0]/))&&(t=e.length);for(var o=r||0,l=i||0;;){var s=e.indexOf("\t",o);if(s<0||t<=s)return l+(t-o);l=(l+=s-o)+(n-l%n),o=s+1}}Y?se=function(e){e.selectionStart=0,e.selectionEnd=e.value.length}:w&&(se=function(e){try{e.select()}catch(e){}});function pe(){this.id=null,this.f=null,this.time=0,this.handler=de(this.onTimeout,this)}function L(e,t){for(var n=0;n<e.length;++n)if(e[n]==t)return n;return-1}pe.prototype.onTimeout=function(e){e.id=0,e.time<=+new Date?e.f():setTimeout(e.handler,e.time-+new Date)};var ge=50,me={toString:function(){return"CodeMirror.Pass"}},ve={scroll:!(pe.prototype.set=function(e,t){this.f=t;t=+new Date+e;(!this.id||t<this.time)&&(clearTimeout(this.id),this.id=setTimeout(this.handler,e),this.time=t)})},ye={origin:"*mouse"},be={origin:"+move"};function we(e,t,n){for(var r=0,i=0;;){var o=e.indexOf("\t",r),l=(o=-1==o?e.length:o)-r;if(o==e.length||t<=i+l)return r+Math.min(l,t-i);if(i+=o-r,r=o+1,t<=(i+=n-i%n))return r}}var xe=[""];function Ce(e){for(;xe.length<=e;)xe.push(z(xe)+" ");return xe[e]}function z(e){return e[e.length-1]}function Se(e,t){for(var n=[],r=0;r<e.length;r++)n[r]=t(e[r],r);return n}function Le(){}function ke(e,t){e=Object.create?Object.create(e):(Le.prototype=e,new Le);return t&&fe(t,e),e}var Te=/[\u00df\u0587\u0590-\u05f4\u0600-\u06ff\u3040-\u309f\u30a0-\u30ff\u3400-\u4db5\u4e00-\u9fcc\uac00-\ud7af]/;function Me(e){return/\w/.test(e)||""<e&&(e.toUpperCase()!=e.toLowerCase()||Te.test(e))}function Ne(e,t){return t?!!(-1<t.source.indexOf("\\w")&&Me(e))||t.test(e):Me(e)}function Oe(e){for(var t in e)if(e.hasOwnProperty(t)&&e[t])return;return 1}var Ae=/[\u0300-\u036f\u0483-\u0489\u0591-\u05bd\u05bf\u05c1\u05c2\u05c4\u05c5\u05c7\u0610-\u061a\u064b-\u065e\u0670\u06d6-\u06dc\u06de-\u06e4\u06e7\u06e8\u06ea-\u06ed\u0711\u0730-\u074a\u07a6-\u07b0\u07eb-\u07f3\u0816-\u0819\u081b-\u0823\u0825-\u0827\u0829-\u082d\u0900-\u0902\u093c\u0941-\u0948\u094d\u0951-\u0955\u0962\u0963\u0981\u09bc\u09be\u09c1-\u09c4\u09cd\u09d7\u09e2\u09e3\u0a01\u0a02\u0a3c\u0a41\u0a42\u0a47\u0a48\u0a4b-\u0a4d\u0a51\u0a70\u0a71\u0a75\u0a81\u0a82\u0abc\u0acLine truncated
|
||||
!function(t){"object"==typeof exports&&"object"==typeof module?t(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],t):t(CodeMirror)}(function(r){"use strict";var p,v,x=r.Pos;function m(t,e){for(var n,i=null!=(i=(n=t).flags)?i:(n.ignoreCase?"i":"")+(n.global?"g":"")+(n.multiline?"m":""),r=i,o=0;o<e.length;o++)-1==r.indexOf(e.charAt(o))&&(r+=e.charAt(o));return i==r?t:new RegExp(t.source,r)}function d(t){return/\\s|\\n|\n|\\W|\\D|\[\^/.test(t.source)}function a(t,e,n){e=m(e,"g");for(var i=n.line,r=n.ch,o=t.lastLine();i<=o;i++,r=0){e.lastIndex=r;var h=t.getLine(i),h=e.exec(h);if(h)return{from:x(i,h.index),to:x(i,h.index+h[0].length),match:h}}}function o(t,e,n){if(!d(e))return a(t,e,n);e=m(e,"gm");for(var i=1,r=n.line,o=t.lastLine();r<=o;){for(var h=0;h<i&&!(o<r);h++)var l=t.getLine(r++),c=null==c?l:c+"\n"+l;i*=2,e.lastIndex=n.ch;var s,f,g,u=e.exec(c);if(u)return g=c.slice(0,u.index).split("\n"),s=u[0].split("\n"),f=n.line+g.length-1,g=g[g.length-1].length,{from:x(f,g),to:x(f+s.length-1,1==s.length?g+s[0].length:s[s.length-1].length),match:u}}}function L(t,e,n){for(var i,r=0;r<=t.length;){e.lastIndex=r;var o=e.exec(t);if(!o)break;var h=o.index+o[0].length;if(h>t.length-n)break;(!i||h>i.index+i[0].length)&&(i=o),r=o.index+1}return i}function O(t,e,n){e=m(e,"g");for(var i=n.line,r=n.ch,o=t.firstLine();o<=i;i--,r=-1){var h=t.getLine(i),h=L(h,e,r<0?0:h.length-r);if(h)return{from:x(i,h.index),to:x(i,h.index+h[0].length),match:h}}}function h(t,e,n){if(!d(e))return O(t,e,n);e=m(e,"gm");for(var i=1,r=t.getLine(n.line).length-n.ch,o=n.line,h=t.firstLine();h<=o;){for(var l=0;l<i&&h<=o;l++)var c=t.getLine(o--),s=null==s?c:c+"\n"+s;i*=2;var f,g,u,a=L(s,e,r);if(a)return u=s.slice(0,a.index).split("\n"),f=a[0].split("\n"),g=o+u.length,u=u[u.length-1].length,{from:x(g,u),to:x(g+f.length-1,1==f.length?u+f[0].length:f[f.length-1].length),match:a}}}function y(t,e,n,i){if(t.length==e.length)return n;for(var r=0,o=n+Math.max(0,t.length-e.length);;){if(r==o)return r;var h=r+o>>1,l=i(t.slice(0,h)).length;if(l==n)return h;n<l?o=h:r=1+h}}function l(t,e,n,i){if(!e.length)return null;var r=i?p:v,o=r(e).split(/\r|\n\r?/);t:for(var h=n.line,l=n.ch,c=t.lastLine()+1-o.length;h<=c;h++,l=0){var s=t.getLine(h).slice(l),f=r(s);if(1==o.length){var g=f.indexOf(o[0]);if(-1==g);else return n=y(s,f,g,r)+l,{from:x(h,y(s,f,g,r)+l),to:x(h,y(s,f,g+o[0].length,r)+l)}}else{g=f.length-o[0].length;if(f.slice(g)==o[0]){for(var u=1;u<o.length-1;u++)if(r(t.getLine(h+u))!=o[u])continue t;var a=t.getLine(h+o.length-1),m=r(a),d=o[o.length-1];if(m.slice(0,d.length)==d)return{from:x(h,y(s,f,g,r)+l),to:x(h+o.length-1,y(a,m,d.length,r))}}}}}function c(t,e,n,i){if(!e.length)return null;var r=i?p:v,o=r(e).split(/\r|\n\r?/);t:for(var h=n.line,l=n.ch,c=t.firstLine()-1+o.length;c<=h;h--,l=-1){var s=t.getLine(h),f=r(s=-1<l?s.slice(0,l):s);if(1==o.length){var g=f.lastIndexOf(o[0]);if(-1!=g)return{from:x(h,y(s,f,g,r)),to:x(h,y(s,f,g+o[0].length,r))}}else{g=o[o.length-1];if(f.slice(0,g.length)==g){for(var u=1,n=h-o.length+1;u<o.length-1;u++)if(r(t.getLine(n+u))!=o[u])continue t;var a=t.getLine(h+1-o.length),m=r(a);if(m.slice(m.length-o[0].length)==o[0])return{from:x(h+1-o.length,y(a,m,a.length-o[0].length,r)),to:x(h,y(s,f,g.length,r))}}}}}function i(n,i,t,e){var r;this.atOccurrence=!1,this.afterEmptyMatch=!1,this.doc=n,t=t?n.clipPos(t):x(0,0),this.pos={from:t,to:t},"object"==typeof e?r=e.caseFold:(r=e,e=null),"string"==typeof i?(null==r&&(r=!1),this.matches=function(t,e){return(t?c:l)(n,i,e,r)}):(i=m(i,"gm"),e&&!1===e.multiline?this.matches=function(t,e){return(t?O:a)(n,i,e)}:this.matches=function(t,e){return(t?h:o)(n,i,e)})}v=String.prototype.normalize?(p=function(t){return t.normalize("NFD").toLowerCase()},function(t){return t.normalize("NFD")}):(p=function(t){return t.toLowerCase()},function(t){return t}),i.prototype={findNext:function(){return this.find(!1)},findPrevious:function(){return this.find(!0)},find:function(t){var e=this.doc.clipPos(t?this.pos.from:this.pos.to);if(this.afterEmptyMatch&&this.atOccurrence&&(e=x(e.line,e.ch),t?(e.ch--,e.ch<0&&(e.line--,e.ch=(this.doc.getLine(e.line)||"").length)):(e.ch++,e.ch>(this.doc.getLine(e.line)||"").length&&(e.ch=0,e.line++)),0!=r.cmpPos(e,this.doc.clipPos(e))))return this.atOccurrence=!1;var e=this.matches(t,e);return this.afterEmptyMatch=e&&0==r.cmpPos(e.from,e.to),e?(this.pos=e,this.atOccurrence=!0,this.pos.match||!0):(e=x(t?this.doc.firstLine():this.doc.lastLine()+1,0),this.pos={from:e,to:e},this.atOccurrence=!1)},from:function(){if(this.atOccurrence)return this.pos.from},to:function(){if(this.atOccurrence)return this.pos.to},replace:function(t,e){this.atOccurrence&&(t=r.splitLines(t),this.doc.replaceRange(t,this.pos.from,this.pos.to,e),this.pos.to=x(this.pos.from.line+t.length-1,t[t.length-1].length+(1==t.length?this.pos.from.ch:0)))}},r.defineExtension("getSearchCursor",function(t,e,n){return new i(this.doc,t,e,n)}),r.defineDocExtension("getSearchCursor",function(t,Line truncated
|
||||
!function(t){"object"==typeof exports&&"object"==typeof module?t(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],t):t(CodeMirror)}(function(r){var u=/MSIE \d/.test(navigator.userAgent)&&(null==document.documentMode||document.documentMode<8),k=r.Pos,p={"(":")>",")":"(<","[":"]>","]":"[<","{":"}>","}":"{<","<":">>",">":"<<"};function y(t){return t&&t.bracketRegex||/[(){}[\]]/}function f(t,e,n){var r=t.getLineHandle(e.line),i=e.ch-1,c=n&&n.afterCursor,a=(null==c&&(c=/(^| )cm-fat-cursor($| )/.test(t.getWrapperElement().className)),y(n)),c=!c&&0<=i&&a.test(r.text.charAt(i))&&p[r.text.charAt(i)]||a.test(r.text.charAt(i+1))&&p[r.text.charAt(++i)];if(!c)return null;a=">"==c.charAt(1)?1:-1;if(n&&n.strict&&0<a!=(i==e.ch))return null;r=t.getTokenTypeAt(k(e.line,i+1)),t=o(t,k(e.line,i+(0<a?1:0)),a,r,n);return null==t?null:{from:k(e.line,i),to:t&&t.pos,match:t&&t.ch==c.charAt(0),forward:0<a}}function o(t,e,n,r,i){for(var c=i&&i.maxScanLineLength||1e4,a=i&&i.maxScanLines||1e3,o=[],h=y(i),l=0<n?Math.min(e.line+a,t.lastLine()+1):Math.max(t.firstLine()-1,e.line-a),s=e.line;s!=l;s+=n){var u=t.getLine(s);if(u){var f=0<n?0:u.length-1,m=0<n?u.length:-1;if(!(u.length>c))for(s==e.line&&(f=e.ch-(n<0?1:0));f!=m;f+=n){var g=u.charAt(f);if(h.test(g)&&(void 0===r||(t.getTokenTypeAt(k(s,f+1))||"")==(r||""))){var d=p[g];if(d&&">"==d.charAt(1)==0<n)o.push(g);else{if(!o.length)return{pos:k(s,f),ch:g};o.pop()}}}}}return s-n!=(0<n?t.lastLine():t.firstLine())&&null}function e(t,e,n){for(var r=t.state.matchBrackets.maxHighlightLineLength||1e3,i=n&&n.highlightNonMatching,c=[],a=t.listSelections(),o=0;o<a.length;o++){var h,l=a[o].empty()&&f(t,a[o].head,n);l&&(l.match||!1!==i)&&t.getLine(l.from.line).length<=r&&(h=l.match?"CodeMirror-matchingbracket":"CodeMirror-nonmatchingbracket",c.push(t.markText(l.from,k(l.from.line,l.from.ch+1),{className:h})),l.to&&t.getLine(l.to.line).length<=r&&c.push(t.markText(l.to,k(l.to.line,l.to.ch+1),{className:h})))}if(c.length){u&&t.state.focused&&t.focus();function s(){t.operation(function(){for(var t=0;t<c.length;t++)c[t].clear()})}if(!e)return s;setTimeout(s,800)}}function i(t){t.operation(function(){t.state.matchBrackets.currentlyHighlighted&&(t.state.matchBrackets.currentlyHighlighted(),t.state.matchBrackets.currentlyHighlighted=null),t.state.matchBrackets.currentlyHighlighted=e(t,!1,t.state.matchBrackets)})}function c(t){t.state.matchBrackets&&t.state.matchBrackets.currentlyHighlighted&&(t.state.matchBrackets.currentlyHighlighted(),t.state.matchBrackets.currentlyHighlighted=null)}r.defineOption("matchBrackets",!1,function(t,e,n){n&&n!=r.Init&&(t.off("cursorActivity",i),t.off("focus",i),t.off("blur",c),c(t)),e&&(t.state.matchBrackets="object"==typeof e?e:{},t.on("cursorActivity",i),t.on("focus",i),t.on("blur",c))}),r.defineExtension("matchBrackets",function(){e(this,!0)}),r.defineExtension("findMatchingBracket",function(t,e,n){return f(this,t,e=!n&&"boolean"!=typeof e?e:n?(n.strict=e,n):e?{strict:!0}:null)}),r.defineExtension("scanForBracket",function(t,e,n,r){return o(this,t,e,n,r)})});;
|
||||
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(v){"use strict";function h(e,t){if(!e.hasOwnProperty(t))throw new Error("Undefined state "+t+" in simple mode")}function k(e,t){if(!e)return/(?:)/;var n="";return e=e instanceof RegExp?(e.ignoreCase&&(n="i"),e.unicode&&(n+="u"),e.source):String(e),new RegExp((!1===t?"":"^")+"(?:"+e+")",n)}function g(e,t){(e.next||e.push)&&h(t,e.next||e.push),this.regex=k(e.regex),this.token=function(e){if(!e)return null;if(e.apply)return e;if("string"==typeof e)return e.replace(/\./g," ");for(var t=[],n=0;n<e.length;n++)t.push(e[n]&&e[n].replace(/\./g," "));return t}(e.token),this.data=e}v.defineSimpleMode=function(e,t){v.defineMode(e,function(e){return v.simpleMode(e,t)})},v.simpleMode=function(e,t){h(t,"start");var n,a={},o=t.meta||{},r=!1;for(n in t)if(n!=o&&t.hasOwnProperty(n))for(var i=a[n]=[],l=t[n],s=0;s<l.length;s++){var d=l[s];i.push(new g(d,t)),(d.indent||d.dedent)&&(r=!0)}var c,p,S,m,u={startState:function(){return{state:"start",pending:null,local:null,localState:null,indent:r?[]:null}},copyState:function(e){var t={state:e.state,pending:e.pending,local:e.local,localState:null,indent:e.indent&&e.indent.slice(0)};e.localState&&(t.localState=v.copyState(e.local.mode,e.localState)),e.stack&&(t.stack=e.stack.slice(0));for(var n=e.persistentStates;n;n=n.next)t.persistentStates={mode:n.mode,spec:n.spec,state:n.state==e.localState?t.localState:v.copyState(n.mode,n.state),next:t.persistentStates};return t},token:(m=e,function(e,t){var n,a;if(t.pending)return a=t.pending.shift(),0==t.pending.length&&(t.pending=null),e.pos+=a.text.length,a.token;if(t.local)return t.local.end&&e.match(t.local.end)?(n=t.local.endToken||null,t.local=t.localState=null):(n=t.local.mode.token(e,t.localState),t.local.endScan&&(a=t.local.endScan.exec(e.current()))&&(e.pos=e.start+a.index)),n;for(var o=S[t.state],r=0;r<o.length;r++){var i=o[r],l=(!i.data.sol||e.sol())&&e.match(i.regex);if(l){if(i.data.next?t.state=i.data.next:i.data.push?((t.stack||(t.stack=[])).push(t.state),t.state=i.data.push):i.data.pop&&t.stack&&t.stack.length&&(t.state=t.stack.pop()),i.data.mode){h=d=f=s=u=p=c=d=void 0;var s,d=m,c=t,p=i.data.mode,u=i.token;if(p.persistent)for(var f=c.persistentStates;f&&!s;f=f.next)(p.spec?function e(t,n){if(t===n)return!0;if(!t||"object"!=typeof t||!n||"object"!=typeof n)return!1;var a=0;for(var o in t)if(t.hasOwnProperty(o)){if(!n.hasOwnProperty(o)||!e(t[o],n[o]))return!1;a++}for(var o in n)n.hasOwnProperty(o)&&a--;return 0==a}(p.spec,f.spec):p.mode==f.mode)&&(s=f);var d=s?s.mode:p.mode||v.getMode(d,p.spec),h=s?s.state:v.startState(d);p.persistent&&!s&&(c.persistentStates={mode:d,spec:p.spec,state:h,next:c.persistentStates}),c.localState=h,c.local={mode:d,end:p.end&&k(p.end),endScan:p.end&&!1!==p.forceEnd&&k(p.end,!1),endToken:u&&u.join?u[u.length-1]:u}}i.data.indent&&t.indent.push(e.indentation()+m.indentUnit),i.data.dedent&&t.indent.pop();h=i.token;if(h&&h.apply&&(h=h(l)),2<l.length&&i.token&&"string"!=typeof i.token){for(var g=2;g<l.length;g++)l[g]&&(t.pending||(t.pending=[])).push({text:l[g],token:i.token[g-1]});return e.backUp(l[0].length-(l[1]?l[1].length:0)),h[0]}return h&&h.join?h[0]:h}}return e.next(),null}),innerMode:function(e){return e.local&&{mode:e.local.mode,state:e.localState}},indent:(c=S=a,function(e,t,n){if(e.local&&e.local.mode.indent)return e.local.mode.indent(e.localState,t,n);if(null==e.indent||e.local||p.dontIndentStates&&-1<function(e,t){for(var n=0;n<t.length;n++)if(t[n]===e)return!0}(e.state,p.dontIndentStates))return v.Pass;var a=e.indent.length-1,o=c[e.state];e:for(;;){for(var r=0;r<o.length;r++){var i=o[r];if(i.data.dedent&&!1!==i.data.dedentIfLineStart){var l=i.regex.exec(t);if(l&&l[0]){a--,(i.next||i.push)&&(o=c[i.next||i.push]),t=t.slice(l[0].length);continue e}}}break}return a<0?0:e.indent[a]})};if(p=o)for(var f in o)o.hasOwnProperty(f)&&(u[f]=o[f]);return u}});;
|
||||
!function(t){"object"==typeof exports&&"object"==typeof module?t(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],t):t(CodeMirror)}(function(y){"use strict";var C={autoSelfClosers:{area:!0,base:!0,br:!0,col:!0,command:!0,embed:!0,frame:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0,menuitem:!0},implicitlyClosed:{dd:!0,li:!0,optgroup:!0,option:!0,p:!0,rp:!0,rt:!0,tbody:!0,td:!0,tfoot:!0,th:!0,tr:!0},contextGrabbers:{dd:{dd:!0,dt:!0},dt:{dd:!0,dt:!0},li:{li:!0},option:{option:!0,optgroup:!0},optgroup:{optgroup:!0},p:{address:!0,article:!0,aside:!0,blockquote:!0,dir:!0,div:!0,dl:!0,fieldset:!0,footer:!0,form:!0,h1:!0,h2:!0,h3:!0,h4:!0,h5:!0,h6:!0,header:!0,hgroup:!0,hr:!0,menu:!0,nav:!0,ol:!0,p:!0,pre:!0,section:!0,table:!0,ul:!0},rp:{rp:!0,rt:!0},rt:{rp:!0,rt:!0},tbody:{tbody:!0,tfoot:!0},td:{td:!0,th:!0},tfoot:{tbody:!0},th:{td:!0,th:!0},thead:{tbody:!0,tfoot:!0},tr:{tr:!0}},doNotIndent:{pre:!0},allowUnquoted:!0,allowMissing:!0,caseFold:!0},z={autoSelfClosers:{},implicitlyClosed:{},contextGrabbers:{},doNotIndent:{},allowUnquoted:!1,allowMissing:!1,allowMissingTagName:!1,caseFold:!1};y.defineMode("xml",function(t,e){var n,i,a,l=t.indentUnit,u={},r=e.htmlMode?C:z;for(n in r)u[n]=r[n];for(n in e)u[n]=e[n];function c(e,n){function t(t){return(n.tokenize=t)(e,n)}var r=e.next();return"<"==r?e.eat("!")?e.eat("[")?e.match("CDATA[")?t(o("atom","]]>")):null:e.match("--")?t(o("comment","--\x3e")):e.match("DOCTYPE",!0,!0)?(e.eatWhile(/[\w\._\-]/),t(function r(o){return function(t,e){for(var n;null!=(n=t.next());){if("<"==n)return e.tokenize=r(o+1),e.tokenize(t,e);if(">"==n){if(1!=o)return e.tokenize=r(o-1),e.tokenize(t,e);e.tokenize=c;break}}return"meta"}}(1))):null:e.eat("?")?(e.eatWhile(/[\w\._\-]/),n.tokenize=o("meta","?>"),"meta"):(i=e.eat("/")?"closeTag":"openTag",n.tokenize=d,"tag bracket"):"&"==r?(e.eat("#")?e.eat("x")?e.eatWhile(/[a-fA-F\d]/)&&e.eat(";"):e.eatWhile(/[\d]/)&&e.eat(";"):e.eatWhile(/[\w\.\-:]/)&&e.eat(";"))?"atom":"error":(e.eatWhile(/[^&<]/),null)}function d(t,e){var n,r,o=t.next();return">"==o||"/"==o&&t.eat(">")?(e.tokenize=c,i=">"==o?"endTag":"selfcloseTag","tag bracket"):"="==o?(i="equals",null):"<"==o?(e.tokenize=c,e.state=p,e.tagName=e.tagStart=null,(n=e.tokenize(t,e))?n+" tag error":"tag error"):/[\'\"]/.test(o)?(e.tokenize=(r=o,a.isInAttribute=!0,a),e.stringStartCol=t.column(),e.tokenize(t,e)):(t.match(/^[^\s\u00a0=<>\"\']*[^\s\u00a0=<>\"\'\/]/),"word");function a(t,e){for(;!t.eol();)if(t.next()==r){e.tokenize=d;break}return"string"}}function o(n,r){return function(t,e){for(;!t.eol();){if(t.match(r)){e.tokenize=c;break}t.next()}return n}}function s(t){return t&&t.toLowerCase()}function f(t,e,n){this.prev=t.context,this.tagName=e||"",this.indent=t.indented,this.startOfLine=n,(u.doNotIndent.hasOwnProperty(e)||t.context&&t.context.noIndent)&&(this.noIndent=!0)}function m(t){t.context&&(t.context=t.context.prev)}function g(t,e){for(var n;;){if(!t.context)return;if(n=t.context.tagName,!u.contextGrabbers.hasOwnProperty(s(n))||!u.contextGrabbers[s(n)].hasOwnProperty(s(e)))return;m(t)}}function p(t,e,n){return"openTag"==t?(n.tagStart=e.column(),h):"closeTag"==t?x:p}function h(t,e,n){return"word"==t?(n.tagName=e.current(),a="tag",w):u.allowMissingTagName&&"endTag"==t?(a="tag bracket",w(t,0,n)):(a="error",h)}function x(t,e,n){var r;return"word"==t?(r=e.current(),n.context&&n.context.tagName!=r&&u.implicitlyClosed.hasOwnProperty(s(n.context.tagName))&&m(n),n.context&&n.context.tagName==r||!1===u.matchClosing?(a="tag",b):(a="tag error",k)):u.allowMissingTagName&&"endTag"==t?(a="tag bracket",b(t,0,n)):(a="error",k)}function b(t,e,n){return"endTag"!=t?(a="error",b):(m(n),p)}function k(t,e,n){return a="error",b(t,0,n)}function w(t,e,n){return"word"==t?(a="attribute",T):"endTag"==t||"selfcloseTag"==t?(r=n.tagName,o=n.tagStart,n.tagName=n.tagStart=null,"selfcloseTag"==t||u.autoSelfClosers.hasOwnProperty(s(r))?g(n,r):(g(n,r),n.context=new f(n,r,o==n.indented)),p):(a="error",w);var r,o}function T(t,e,n){return"equals"==t?v:(u.allowMissing||(a="error"),w(t,0,n))}function v(t,e,n){return"string"==t?N:"word"==t&&u.allowUnquoted?(a="string",w):(a="error",w(t,0,n))}function N(t,e,n){return"string"==t?N:w(t,0,n)}return c.isInText=!0,{startState:function(t){var e={tokenize:c,state:p,indented:t||0,tagName:null,tagStart:null,context:null};return null!=t&&(e.baseIndent=t),e},token:function(t,e){if(!e.tagName&&t.sol()&&(e.indented=t.indentation()),t.eatSpace())return null;i=null;var n=e.tokenize(t,e);return(n||i)&&"comment"!=n&&(a=null,e.state=e.state(i||n,t,e),a&&(n="error"==a?n+" error":a)),n},indent:function(t,e,n){var r=t.context;if(t.tokenize.isInAttribute)return t.tagStart==t.indented?t.stringStartCol+1:t.indented+l;if(r&&r.noIndent)return y.Pass;if(t.tokenize!=d&&t.tokenize!=c)return n?n.match(/^(\s*)/)[0].length:0;if(t.tagName)return!1!==u.multilineTagIndentPastTag?t.tagStart+t.tagName.length+2:t.tagStart+l*(u.multilinLine truncated
|
||||
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(rt){"use strict";rt.defineMode("javascript",function(e,l){var t,r,O,P,f=e.indentUnit,N=l.statementIndent,U=l.jsonld,o=l.json||U,W=!1!==l.trackScope,u=l.typescript,B=l.wordCharacters||/[\w$\xa1-\uffff]/,F=(e=n("keyword a"),t=n("keyword b"),r=n("keyword c"),O=n("keyword d"),P=n("operator"),{if:n("if"),while:e,with:e,else:t,do:t,try:t,finally:t,return:O,break:O,continue:O,new:n("new"),delete:r,void:r,throw:r,debugger:n("debugger"),var:n("var"),const:n("var"),let:n("var"),function:n("function"),catch:n("catch"),for:n("for"),switch:n("switch"),case:n("case"),default:n("default"),in:P,typeof:P,instanceof:P,true:e={type:"atom",style:"atom"},false:e,null:e,undefined:e,NaN:e,Infinity:e,this:n("this"),class:n("class"),super:n("atom"),yield:r,export:n("export"),import:n("import"),extends:r,await:r});function n(e){return{type:e,style:"keyword"}}var H,D,G=/[+\-*&%=<>!?|~^@]/,J=/^@(context|id|value|language|type|container|list|set|reverse|index|base|vocab|graph)"/;function i(e,t,r){return H=e,D=r,t}function d(e,t){var a,r=e.next();if('"'==r||"'"==r)return t.tokenize=(a=r,function(e,t){var r,n=!1;if(U&&"@"==e.peek()&&e.match(J))return t.tokenize=d,i("jsonld-keyword","meta");for(;null!=(r=e.next())&&(r!=a||n);)n=!n&&"\\"==r;return n||(t.tokenize=d),i("string","string")}),t.tokenize(e,t);if("."==r&&e.match(/^\d[\d_]*(?:[eE][+\-]?[\d_]+)?/))return i("number","number");if("."==r&&e.match(".."))return i("spread","meta");if(/[\[\]{}\(\),;\:\.]/.test(r))return i(r);if("="==r&&e.eat(">"))return i("=>","operator");if("0"==r&&e.match(/^(?:x[\dA-Fa-f_]+|o[0-7_]+|b[01_]+)n?/))return i("number","number");if(/\d/.test(r))return e.match(/^[\d_]*(?:n|(?:\.[\d_]*)?(?:[eE][+\-]?[\d_]+)?)?/),i("number","number");if("/"==r)return e.eat("*")?(t.tokenize=K)(e,t):e.eat("/")?(e.skipToEnd(),i("comment","comment")):tt(e,t,1)?(function(e){for(var t,r=!1,n=!1;null!=(t=e.next());){if(!r){if("/"==t&&!n)return;"["==t?n=!0:n&&"]"==t&&(n=!1)}r=!r&&"\\"==t}}(e),e.match(/^\b(([gimyus])(?![gimyus]*\2))+\b/),i("regexp","string-2")):(e.eat("="),i("operator","operator",e.current()));if("`"==r)return(t.tokenize=L)(e,t);if("#"==r&&"!"==e.peek())return e.skipToEnd(),i("meta","meta");if("#"==r&&e.eatWhile(B))return i("variable","property");if("<"==r&&e.match("!--")||"-"==r&&e.match("->")&&!/\S/.test(e.string.slice(0,e.start)))return e.skipToEnd(),i("comment","comment");if(G.test(r))return">"==r&&t.lexical&&">"==t.lexical.type||(e.eat("=")?"!"!=r&&"="!=r||e.eat("="):/[<>*+\-|&?]/.test(r)&&(e.eat(r),">"==r&&e.eat(r))),"?"==r&&e.eat(".")?i("."):i("operator","operator",e.current());if(B.test(r)){e.eatWhile(B);r=e.current();if("."!=t.lastType){if(F.propertyIsEnumerable(r))return i((t=F[r]).type,t.style,r);if("async"==r&&e.match(/^(\s|\/\*([^*]|\*(?!\/))*?\*\/)*[\[\(\w]/,!1))return i("async","keyword",r)}return i("variable","variable",r)}}function K(e,t){for(var r,n=!1;r=e.next();){if("/"==r&&n){t.tokenize=d;break}n="*"==r}return i("comment","comment")}function L(e,t){for(var r,n=!1;null!=(r=e.next());){if(!n&&("`"==r||"$"==r&&e.eat("{"))){t.tokenize=d;break}n=!n&&"\\"==r}return i("quasi","string-2",e.current())}function Q(e,t){t.fatArrowAt&&(t.fatArrowAt=null);var r=e.string.indexOf("=>",e.start);if(!(r<0)){!u||(n=/:\s*(?:\w+(?:<[^>]*>|\[\])?|\{[^}]*\})\s*$/.exec(e.string.slice(e.start,r)))&&(r=n.index);for(var n,a=0,i=!1,o=r-1;0<=o;--o){var c=e.string.charAt(o),s="([{}])".indexOf(c);if(0<=s&&s<3){if(!a){++o;break}if(0==--a){"("==c&&(i=!0);break}}else if(3<=s&&s<6)++a;else if(B.test(c))i=!0;else if(/["'\/`]/.test(c))for(;;--o){if(0==o)return;if(e.string.charAt(o-1)==c&&"\\"!=e.string.charAt(o-2)){o--;break}}else if(i&&!a){++o;break}}i&&!a&&(t.fatArrowAt=o)}}var R={atom:!0,number:!0,variable:!0,string:!0,regexp:!0,this:!0,import:!0,"jsonld-keyword":!0};function X(e,t,r,n,a,i){this.indented=e,this.column=t,this.type=r,this.prev=a,this.info=i,null!=n&&(this.align=n)}function Y(e,t,r,n,a){var i=e.cc;for(c.state=e,c.stream=a,c.marked=null,c.cc=i,c.style=t,e.lexical.hasOwnProperty("align")||(e.lexical.align=!0);;)if((i.length?i.pop():o?x:b)(r,n)){for(;i.length&&i[i.length-1].lex;)i.pop()();return c.marked?c.marked:"variable"==r&&function(e,t){if(W){for(var r=e.localVars;r;r=r.next)if(r.name==t)return 1;for(var n=e.context;n;n=n.prev)for(r=n.vars;r;r=r.next)if(r.name==t)return 1}}(e,n)?"variable-2":t}}var c={state:null,column:null,marked:null,cc:null};function s(){for(var e=arguments.length-1;0<=e;e--)c.cc.push(arguments[e])}function p(){return s.apply(null,arguments),!0}function Z(e,t){for(var r=t;r;r=r.next)if(r.name==e)return 1}function a(e){var t=c.state;if(c.marked="def",W){if(t.context)if("var"==t.lexical.info&&t.context&&t.context.block){var r=function e(t,r){{var n;return r?r.block?(n=e(t,r.prev))?n==r.prev?r:new te(n,r.vars,!0):null:Z(t,r.vars)?r:new te(r.prev,new re(t,r.varLine truncated
|
||||
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(T){"use strict";function e(e){for(var t={},r=0;r<e.length;++r)t[e[r].toLowerCase()]=!0;return t}T.defineMode("css",function(e,t){var a,i,r=t.inline,n=(t.propertyKeywords||(t=T.resolveMode("text/css")),e.indentUnit),l=t.tokenHooks,o=t.documentTypes||{},s=t.mediaTypes||{},c=t.mediaFeatures||{},d=t.mediaValueKeywords||{},p=t.propertyKeywords||{},u=t.nonStandardPropertyKeywords||{},m=t.fontProperties||{},g=t.counterDescriptors||{},b=t.colorKeywords||{},h=t.valueKeywords||{},f=t.allowNested,k=t.lineComment,y=!0===t.supportsAtComponent,w=!1!==e.highlightNonStandardPropertyKeywords;function v(e,t){return a=t,e}function x(i){return function(e,t){for(var r,o=!1;null!=(r=e.next());){if(r==i&&!o){")"==i&&e.backUp(1);break}o=!o&&"\\"==r}return r!=i&&(o||")"==i)||(t.tokenize=null),a="string"}}function z(e,t){return e.next(),e.match(/^\s*[\"\')]/,!1)?t.tokenize=null:t.tokenize=x(")"),a="(",null}function j(e,t,r){this.type=e,this.indent=t,this.prev=r}function P(e,t,r,o){return e.context=new j(r,t.indentation()+(!1===o?0:n),e.context),r}function K(e){return e.context.prev&&(e.context=e.context.prev),e.context.type}function q(e,t,r){return _[r.context.type](e,t,r)}function C(e,t,r,o){for(var i=o||1;0<i;i--)r.context=r.context.prev;return q(e,t,r)}function B(e){e=e.current().toLowerCase();i=h.hasOwnProperty(e)?"atom":b.hasOwnProperty(e)?"keyword":"variable"}var _={top:function(e,t,r){if("{"==e)return P(r,t,"block");if("}"==e&&r.context.prev)return K(r);if(y&&/@component/i.test(e))return P(r,t,"atComponentBlock");if(/^@(-moz-)?document$/i.test(e))return P(r,t,"documentTypes");if(/^@(media|supports|(-moz-)?document|import)$/i.test(e))return P(r,t,"atBlock");if(/^@(font-face|counter-style)/i.test(e))return r.stateArg=e,"restricted_atBlock_before";if(/^@(-(moz|ms|o|webkit)-)?keyframes$/i.test(e))return"keyframes";if(e&&"@"==e.charAt(0))return P(r,t,"at");if("hash"==e)i="builtin";else if("word"==e)i="tag";else{if("variable-definition"==e)return"maybeprop";if("interpolation"==e)return P(r,t,"interpolation");if(":"==e)return"pseudo";if(f&&"("==e)return P(r,t,"parens")}return r.context.type},block:function(e,t,r){var o;return"word"==e?(o=t.current().toLowerCase(),p.hasOwnProperty(o)?(i="property","maybeprop"):u.hasOwnProperty(o)?(i=w?"string-2":"property","maybeprop"):f?(i=t.match(/^\s*:(?:\s|$)/,!1)?"property":"tag","block"):(i+=" error","maybeprop")):"meta"==e?"block":f||"hash"!=e&&"qualifier"!=e?_.top(e,t,r):(i="error","block")},maybeprop:function(e,t,r){return":"==e?P(r,t,"prop"):q(e,t,r)},prop:function(e,t,r){if(";"==e)return K(r);if("{"==e&&f)return P(r,t,"propBlock");if("}"==e||"{"==e)return C(e,t,r);if("("==e)return P(r,t,"parens");if("hash"!=e||/^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(t.current())){if("word"==e)B(t);else if("interpolation"==e)return P(r,t,"interpolation")}else i+=" error";return"prop"},propBlock:function(e,t,r){return"}"==e?K(r):"word"==e?(i="property","maybeprop"):r.context.type},parens:function(e,t,r){return"{"==e||"}"==e?C(e,t,r):")"==e?K(r):"("==e?P(r,t,"parens"):"interpolation"==e?P(r,t,"interpolation"):("word"==e&&B(t),"parens")},pseudo:function(e,t,r){return"meta"==e?"pseudo":"word"==e?(i="variable-3",r.context.type):q(e,t,r)},documentTypes:function(e,t,r){return"word"==e&&o.hasOwnProperty(t.current())?(i="tag",r.context.type):_.atBlock(e,t,r)},atBlock:function(e,t,r){return"("==e?P(r,t,"atBlock_parens"):"}"==e||";"==e?C(e,t,r):"{"==e?K(r)&&P(r,t,f?"block":"top"):"interpolation"==e?P(r,t,"interpolation"):("word"==e&&(e=t.current().toLowerCase(),i="only"==e||"not"==e||"and"==e||"or"==e?"keyword":s.hasOwnProperty(e)?"attribute":c.hasOwnProperty(e)?"property":d.hasOwnProperty(e)?"keyword":p.hasOwnProperty(e)?"property":u.hasOwnProperty(e)?w?"string-2":"property":h.hasOwnProperty(e)?"atom":b.hasOwnProperty(e)?"keyword":"error"),r.context.type)},atComponentBlock:function(e,t,r){return"}"==e?C(e,t,r):"{"==e?K(r)&&P(r,t,f?"block":"top",!1):("word"==e&&(i="error"),r.context.type)},atBlock_parens:function(e,t,r){return")"==e?K(r):"{"==e||"}"==e?C(e,t,r,2):_.atBlock(e,t,r)},restricted_atBlock_before:function(e,t,r){return"{"==e?P(r,t,"restricted_atBlock"):"word"==e&&"@counter-style"==r.stateArg?(i="variable","restricted_atBlock_before"):q(e,t,r)},restricted_atBlock:function(e,t,r){return"}"==e?(r.stateArg=null,K(r)):"word"==e?(i="@font-face"==r.stateArg&&!m.hasOwnProperty(t.current().toLowerCase())||"@counter-style"==r.stateArg&&!g.hasOwnProperty(t.current().toLowerCase())?"error":"property","maybeprop"):"restricted_atBlock"},keyframes:function(e,t,r){return"word"==e?(i="variable","keyframes"):"{"==e?P(r,t,"top"):q(e,t,r)},at:function(e,t,r){return";"==e?K(r):"{"==e||"}"==e?C(e,t,r):("word"==e?i="tag":"hash"==e&&(i="builtin"),"at")},interpolation:function(e,t,r){return"}"==e?K(r):"{"==e||";"==e?C(e,tLine truncated
|
||||
!function(t){"object"==typeof exports&&"object"==typeof module?t(require("../../lib/codemirror"),require("../xml/xml"),require("../javascript/javascript"),require("../css/css")):"function"==typeof define&&define.amd?define(["../../lib/codemirror","../xml/xml","../javascript/javascript","../css/css"],t):t(CodeMirror)}(function(m){"use strict";var l={script:[["lang",/(javascript|babel)/i,"javascript"],["type",/^(?:text|application)\/(?:x-)?(?:java|ecma)script$|^module$|^$/i,"javascript"],["type",/./,"text/plain"],[null,null,"javascript"]],style:[["lang",/^css$/i,"css"],["type",/^(text\/)?(x-)?(stylesheet|css)$/i,"css"],["type",/./,"text/plain"],[null,null,"css"]]};var a={};function d(t,e){e=t.match(a[t=e]||(a[t]=new RegExp("\\s+"+t+"\\s*=\\s*('|\")?([^'\"]+)('|\")?\\s*")));return e?/^\s*(.*?)\s*$/.exec(e[2])[1]:""}function g(t,e){return new RegExp((e?"^":"")+"</\\s*"+t+"\\s*>","i")}function o(t,e){for(var a in t)for(var n=e[a]||(e[a]=[]),l=t[a],o=l.length-1;0<=o;o--)n.unshift(l[o])}m.defineMode("htmlmixed",function(i,t){var c=m.getMode(i,{name:"xml",htmlMode:!0,multilineTagIndentFactor:t.multilineTagIndentFactor,multilineTagIndentPastTag:t.multilineTagIndentPastTag,allowMissingTagName:t.allowMissingTagName}),s={},e=t&&t.tags,a=t&&t.scriptTypes;if(o(l,s),e&&o(e,s),a)for(var n=a.length-1;0<=n;n--)s.script.unshift(["type",a[n].matches,a[n].mode]);function u(t,e){var a,o,r,n=c.token(t,e.htmlState),l=/\btag\b/.test(n);return l&&!/[<>\s\/]/.test(t.current())&&(a=e.htmlState.tagName&&e.htmlState.tagName.toLowerCase())&&s.hasOwnProperty(a)?e.inTag=a+" ":e.inTag&&l&&/>$/.test(t.current())?(a=/^([\S]+) (.*)/.exec(e.inTag),e.inTag=null,l=">"==t.current()&&function(t,e){for(var a=0;a<t.length;a++){var n=t[a];if(!n[0]||n[1].test(d(e,n[0])))return n[2]}}(s[a[1]],a[2]),l=m.getMode(i,l),o=g(a[1],!0),r=g(a[1],!1),e.token=function(t,e){return t.match(o,!1)?(e.token=u,e.localState=e.localMode=null):(a=t,n=r,t=e.localMode.token(t,e.localState),e=a.current(),-1<(l=e.search(n))?a.backUp(e.length-l):e.match(/<\/?$/)&&(a.backUp(e.length),a.match(n,!1)||a.match(e)),t);var a,n,l},e.localMode=l,e.localState=m.startState(l,c.indent(e.htmlState,"",""))):e.inTag&&(e.inTag+=t.current(),t.eol()&&(e.inTag+=" ")),n}return{startState:function(){return{token:u,inTag:null,localMode:null,localState:null,htmlState:m.startState(c)}},copyState:function(t){var e;return t.localState&&(e=m.copyState(t.localMode,t.localState)),{token:t.token,inTag:t.inTag,localMode:t.localMode,localState:e,htmlState:m.copyState(c,t.htmlState)}},token:function(t,e){return e.token(t,e)},indent:function(t,e,a){return!t.localMode||/^\s*<\//.test(e)?c.indent(t.htmlState,e,a):t.localMode.indent?t.localMode.indent(t.localState,e,a):m.Pass},innerMode:function(t){return{state:t.localState||t.htmlState,mode:t.localMode||c}}}},"xml","javascript","css"),m.defineMIME("text/html","htmlmixed")});;
|
||||
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(M){"use strict";function L(e,t,n,r,o,a){this.indented=e,this.column=t,this.type=n,this.info=r,this.align=o,this.prev=a}function D(e,t,n,r){var o=e.indented;e.context&&"statement"==e.context.type&&"statement"!=n&&(o=e.context.indented),e.context=new L(o,t,n,r,null,e.context)}function P(e){var t=e.context.type;return")"!=t&&"]"!=t&&"}"!=t||(e.indented=e.context.indented),e.context=e.context.prev}function E(e,t,n){return"variable"==t.prevToken||"type"==t.prevToken||(!!/\S(?:[^- ]>|[*\]])\s*$|\*$/.test(e.string.slice(0,n))||(!(!t.typeAtEndOfLine||e.column()!=e.indentation())||void 0))}function z(e){for(;;){if(!e||"top"==e.type)return 1;if("}"==e.type&&"namespace"!=e.prev.info)return;e=e.prev}}function e(e){for(var t={},n=e.split(" "),r=0;r<n.length;++r)t[n[r]]=!0;return t}function F(e,t){return"function"==typeof e?e(t):e.propertyIsEnumerable(t)}M.defineMode("clike",function(e,a){var i,l,s=e.indentUnit,c=a.statementIndentUnit||s,u=a.dontAlignCalls,o=a.keywords||{},d=a.types||{},f=a.builtin||{},p=a.blockKeywords||{},m=a.defKeywords||{},h=a.atoms||{},y=a.hooks||{},g=a.multiLineStrings,k=!1!==a.indentStatements,e=!1!==a.indentSwitch,x=a.namespaceSeparator,b=a.isPunctuationChar||/[\[\]{}\(\),;\:\.]/,v=a.numberStart||/[\d\.]/,w=a.number||/^(?:0x[a-f\d]+|0b[01]+|(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?)(u|ll?|l|f)?/i,_=a.isOperatorChar||/[+\-*&%=<>!?|\/]/,S=a.isIdentifierChar||/[\w\$_\xa1-\uffff]/,T=a.isReservedIdentifier||!1;function C(e,t){var a,n=e.next();if(y[n]){var r=y[n](e,t);if(!1!==r)return r}if('"'==n||"'"==n)return t.tokenize=(a=n,function(e,t){for(var n,r=!1,o=!1;null!=(n=e.next());){if(n==a&&!r){o=!0;break}r=!r&&"\\"==n}return!o&&(r||g)||(t.tokenize=null),"string"}),t.tokenize(e,t);if(v.test(n)){if(e.backUp(1),e.match(w))return"number";e.next()}if(b.test(n))return i=n,null;if("/"==n){if(e.eat("*"))return(t.tokenize=I)(e,t);if(e.eat("/"))return e.skipToEnd(),"comment"}if(_.test(n)){for(;!e.match(/^\/[\/*]/,!1)&&e.eat(_););return"operator"}if(e.eatWhile(S),x)for(;e.match(x);)e.eatWhile(S);r=e.current();return F(o,r)?(F(p,r)&&(i="newstatement"),F(m,r)&&(l=!0),"keyword"):F(d,r)?"type":F(f,r)||T&&T(r)?(F(p,r)&&(i="newstatement"),"builtin"):F(h,r)?"atom":"variable"}function I(e,t){for(var n,r=!1;n=e.next();){if("/"==n&&r){t.tokenize=null;break}r="*"==n}return"comment"}function N(e,t){a.typeFirstDefinitions&&e.eol()&&z(t.context)&&(t.typeAtEndOfLine=E(e,t,e.pos))}return{startState:function(e){return{tokenize:null,context:new L((e||0)-s,0,"top",null,!1),indented:0,startOfLine:!0,prevToken:null}},token:function(e,t){var n=t.context;if(e.sol()&&(null==n.align&&(n.align=!1),t.indented=e.indentation(),t.startOfLine=!0),e.eatSpace())return N(e,t),null;i=l=null;var r,o=(t.tokenize||C)(e,t);if("comment"==o||"meta"==o)return o;if(null==n.align&&(n.align=!0),";"==i||":"==i||","==i&&e.match(/^\s*(?:\/\/.*)?$/,!1))for(;"statement"==t.context.type;)P(t);else if("{"==i)D(t,e.column(),"}");else if("["==i)D(t,e.column(),"]");else if("("==i)D(t,e.column(),")");else if("}"==i){for(;"statement"==n.type;)n=P(t);for("}"==n.type&&(n=P(t));"statement"==n.type;)n=P(t)}else i==n.type?P(t):k&&(("}"==n.type||"top"==n.type)&&";"!=i||"statement"==n.type&&"newstatement"==i)&&D(t,e.column(),"statement",e.current());return"variable"==o&&("def"==t.prevToken||a.typeFirstDefinitions&&E(e,t,e.start)&&z(t.context)&&e.match(/^\s*\(/,!1))&&(o="def"),"def"==(o=y.token&&void 0!==(r=y.token(e,t,o))?r:o)&&!1===a.styleDefs&&(o="variable"),t.startOfLine=!1,t.prevToken=l?"def":o||i,N(e,t),o},indent:function(e,t){if(e.tokenize!=C&&null!=e.tokenize||e.typeAtEndOfLine&&z(e.context))return M.Pass;var n=e.context,r=t&&t.charAt(0),o=r==n.type;if("statement"==n.type&&"}"==r&&(n=n.prev),a.dontIndentStatements)for(;"statement"==n.type&&a.dontIndentStatements.test(n.info);)n=n.prev;if(y.indent){e=y.indent(e,n,t,s);if("number"==typeof e)return e}e=n.prev&&"switch"==n.prev.info;if(a.allmanIndentation&&/[{(]/.test(r)){for(;"top"!=n.type&&"}"!=n.type;)n=n.prev;return n.indented}return"statement"==n.type?n.indented+("{"==r?0:c):!n.align||u&&")"==n.type?")"!=n.type||o?n.indented+(o?0:s)+(o||!e||/^(?:case|default)\b/.test(t)?0:s):n.indented+c:n.column+(o?0:1)},electricInput:e?/^\s*(?:case .*?:|default:|\{\}?|\})$/:/^\s*[{}]$/,blockCommentStart:"/*",blockCommentEnd:"*/",blockCommentContinue:" * ",lineComment:"//",fold:"brace"}});var t="auto if break case register continue return default do sizeof static else struct switch extern typedef union for goto while enum const volatile inline restrict asm fortran",n="alignas alignof and and_eq audit axiom bitand bitor catch class compl concept constexpr const_cast decltype delete dynamic_cast explicit export final friend import module mutable namespace new noexcept not not_eq operator or or_eq override private protected public reinterpret_cast requLine truncated
|
||||
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror"),require("../htmlmixed/htmlmixed"),require("../clike/clike")):"function"==typeof define&&define.amd?define(["../../lib/codemirror","../htmlmixed/htmlmixed","../clike/clike"],e):e(CodeMirror)}(function(a){"use strict";function e(e){for(var t={},_=e.split(" "),r=0;r<_.length;++r)t[_[r]]=!0;return t}function o(s,i,l){return 0==s.length?n(i):function(e,t){for(var _=s[0],r=0;r<_.length;r++)if(e.match(_[r][0]))return t.tokenize=o(s.slice(1),i),_[r][1];return t.tokenize=n(i,l),"string"}}function n(n,a){return function(e,t){var _=e,r=t,s=n,i=a;if(!1!==i&&_.match("${",!1)||_.match("{$",!1))return r.tokenize=null,"string";if(!1!==i&&_.match(/^\$[a-zA-Z_][a-zA-Z0-9_]*/))return _.match("[",!1)&&(r.tokenize=o([[["[",null]],[[/\d[\w\.]*/,"number"],[/\$[a-zA-Z_][a-zA-Z0-9_]*/,"variable-2"],[/[\w\$]+/,"variable"]],[["]",null]]],s,i)),_.match(/^->\w/,!1)&&(r.tokenize=o([[["->",null]],[[/[\w]+/,"variable"]]],s,i)),"variable-2";for(var l=!1;!_.eol()&&(l||!1===i||!_.match("{$",!1)&&!_.match(/^(\$[a-zA-Z_][a-zA-Z0-9_]*|\$\{)/,!1));){if(!l&&_.match(s)){r.tokenize=null,r.tokStack.pop(),r.tokStack.pop();break}l="\\"==_.next()&&!l}return"string"}}var t="abstract and array as break case catch class clone const continue declare default do else elseif enddeclare endfor endforeach endif endswitch endwhile enum extends final for foreach function global goto if implements interface instanceof namespace new or private protected public static switch throw trait try use var while xor die echo empty exit eval include include_once isset list require require_once return print unset __halt_compiler self static parent yield insteadof finally readonly match",_="true false null TRUE FALSE NULL __CLASS__ __DIR__ __FILE__ __LINE__ __METHOD__ __FUNCTION__ __NAMESPACE__ __TRAIT__",r="func_num_args func_get_arg func_get_args strlen strcmp strncmp strcasecmp strncasecmp each error_reporting define defined trigger_error user_error set_error_handler restore_error_handler get_declared_classes get_loaded_extensions extension_loaded get_extension_funcs debug_backtrace constant bin2hex hex2bin sleep usleep time mktime gmmktime strftime gmstrftime strtotime date gmdate getdate localtime checkdate flush wordwrap htmlspecialchars htmlentities html_entity_decode md5 md5_file crc32 getimagesize image_type_to_mime_type phpinfo phpversion phpcredits strnatcmp strnatcasecmp substr_count strspn strcspn strtok strtoupper strtolower strpos strrpos strrev hebrev hebrevc nl2br basename dirname pathinfo stripslashes stripcslashes strstr stristr strrchr str_shuffle str_word_count strcoll substr substr_replace quotemeta ucfirst ucwords strtr addslashes addcslashes rtrim str_replace str_repeat count_chars chunk_split trim ltrim strip_tags similar_text explode implode setlocale localeconv parse_str str_pad chop strchr sprintf printf vprintf vsprintf sscanf fscanf parse_url urlencode urldecode rawurlencode rawurldecode readlink linkinfo link unlink exec system escapeshellcmd escapeshellarg passthru shell_exec proc_open proc_close rand srand getrandmax mt_rand mt_srand mt_getrandmax base64_decode base64_encode abs ceil floor round is_finite is_nan is_infinite bindec hexdec octdec decbin decoct dechex base_convert number_format fmod ip2long long2ip getenv putenv getopt microtime gettimeofday getrusage uniqid quoted_printable_decode set_time_limit get_cfg_var magic_quotes_runtime set_magic_quotes_runtime get_magic_quotes_gpc get_magic_quotes_runtime import_request_variables error_log serialize unserialize memory_get_usage memory_get_peak_usage var_dump var_export debug_zval_dump print_r highlight_file show_source highlight_string ini_get ini_get_all ini_set ini_alter ini_restore get_include_path set_include_path restore_include_path setcookie header headers_sent connection_aborted connection_status ignore_user_abort parse_ini_file is_uploaded_file move_uploaded_file intval floatval doubleval strval gettype settype is_null is_resource is_bool is_long is_float is_int is_integer is_double is_real is_numeric is_string is_array is_object is_scalar ereg ereg_replace eregi eregi_replace split spliti join sql_regcase dl pclose popen readfile rewind rmdir umask fclose feof fgetc fgets fgetss fread fopen fpassthru ftruncate fstat fseek ftell fflush fwrite fputs mkdir rename copy tempnam tmpfile file file_get_contents file_put_contents stream_select stream_context_create stream_context_set_params stream_context_set_option stream_context_get_options stream_filter_prepend stream_filter_append fgetcsv flock get_meta_tags stream_set_write_buffer set_file_buffer set_socket_blocking stream_set_blocking socket_set_blocking stream_get_meta_data stream_register_wrapper stream_wrapper_register stream_set_timeout socket_set_timeout socket_get_status realpath fnmatch fsockopen pfsockopen pack unpack get_browser crypt opendir closedir chdir getcwd rewinddir readdir dir glob fileatime filectime filegroup fileiLine truncated
|
||||
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(x){"use strict";function k(e){return new RegExp("^(("+e.join(")|(")+"))\\b")}var _=k(["and","or","not","is"]),v=["as","assert","break","class","continue","def","del","elif","else","except","finally","for","from","global","if","import","lambda","pass","raise","return","try","while","with","yield","in","False","True"],z=["abs","all","any","bin","bool","bytearray","callable","chr","classmethod","compile","complex","delattr","dict","dir","divmod","enumerate","eval","filter","float","format","frozenset","getattr","globals","hasattr","hash","help","hex","id","input","int","isinstance","issubclass","iter","len","list","locals","map","max","memoryview","min","next","object","oct","open","ord","pow","property","range","repr","reversed","round","set","setattr","slice","sorted","staticmethod","str","sum","super","tuple","type","vars","zip","__import__","NotImplemented","Ellipsis","__debug__"];function w(e){return e.scopes[e.scopes.length-1]}x.registerHelper("hintWords","python",v.concat(z).concat(["exec","print"])),x.defineMode("python",function(t,l){for(var s="error",o=l.delimiters||l.singleDelimiters||/^[\(\)\[\]\{\}@,:`=;\.\\]/,a=[l.singleOperators,l.doubleOperators,l.doubleDelimiters,l.tripleDelimiters,l.operators||/^([-+*/%\/&|^]=?|[<>=]+|\/\/=?|\*\*=?|!=|[~!@]|\.\.\.)/],e=0;e<a.length;e++)a[e]||a.splice(e--,1);var c,u,f=l.hangingIndent||t.indentUnit,n=v,r=z,p=(null!=l.extra_keywords&&(n=n.concat(l.extra_keywords)),null!=l.extra_builtins&&(r=r.concat(l.extra_builtins)),!(l.version&&Number(l.version)<3)),d=(u=p?(c=l.identifiers||/^[_A-Za-z\u00A1-\uFFFF][_A-Za-z0-9\u00A1-\uFFFF]*/,n=n.concat(["nonlocal","None","aiter","anext","async","await","breakpoint","match","case"]),r=r.concat(["ascii","bytes","exec","print"]),new RegExp("^(([rbuf]|(br)|(rb)|(fr)|(rf))?('{3}|\"{3}|['\"]))","i")):(c=l.identifiers||/^[_A-Za-z][_A-Za-z0-9]*/,n=n.concat(["exec","print"]),r=r.concat(["apply","basestring","buffer","cmp","coerce","execfile","file","intern","long","raw_input","reduce","reload","unichr","unicode","xrange","None"]),new RegExp("^(([rubf]|(ur)|(br))?('{3}|\"{3}|['\"]))","i")),k(n)),m=k(r);function i(e,t){var n,r=e.sol()&&"\\"!=t.lastToken;return r&&(t.indent=e.indentation()),r&&"py"==w(t).type?(r=w(t).offset,e.eatSpace()?(r<(n=e.indentation())?b(t):n<r&&y(e,t)&&"#"!=e.peek()&&(t.errorToken=!0),null):(n=h(e,t),0<r&&y(e,t)&&(n+=" "+s),n)):h(e,t)}function h(e,t,n){if(e.eatSpace())return null;if(!n&&e.match(/^#.*/))return"comment";if(e.match(/^[0-9\.]/,!1)){var r=!1;if(e.match(/^[\d_]*\.\d+(e[\+\-]?\d+)?/i)&&(r=!0),e.match(/^[\d_]+\.\d*/)&&(r=!0),r=e.match(/^\.\d+/)?!0:r)return e.eat(/J/i),"number";r=!1;if(e.match(/^0x[0-9a-f_]+/i)&&(r=!0),e.match(/^0b[01_]+/i)&&(r=!0),e.match(/^0o[0-7_]+/i)&&(r=!0),e.match(/^[1-9][\d_]*(e[\+\-]?[\d_]+)?/)&&(e.eat(/J/i),r=!0),r=e.match(/^0(?![\dx])/i)?!0:r)return e.eat(/L/i),"number"}if(e.match(u))return r=-1!==e.current().toLowerCase().indexOf("f"),t.tokenize=(r?function(n,r){for(;0<="rubf".indexOf(n.charAt(0).toLowerCase());)n=n.substr(1);var i=1==n.length,o="string";function a(e,t){for(;!e.eol();)if(e.eatWhile(/[^'"\{\}\\]/),e.eat("\\")){if(e.next(),i&&e.eol())return o}else{if(e.match(n))return t.tokenize=r,o;if(e.match("{{"))return o;if(e.match("{",!1))return t.tokenize=function r(i){return function(e,t){var n=h(e,t,!0);return"punctuation"==n&&("{"==e.current()?t.tokenize=r(i+1):"}"==e.current()&&(t.tokenize=1<i?r(i-1):a)),n}}(0),e.current()?o:t.tokenize(e,t);if(e.match("}}"))return o;if(e.match("}"))return s;e.eat(/['"]/)}if(i){if(l.singleLineStringErrors)return s;t.tokenize=r}return o}return a.isString=!0,a}:function(n,r){for(;0<="rubf".indexOf(n.charAt(0).toLowerCase());)n=n.substr(1);var i=1==n.length,o="string";function e(e,t){for(;!e.eol();)if(e.eatWhile(/[^'"\\]/),e.eat("\\")){if(e.next(),i&&e.eol())return o}else{if(e.match(n))return t.tokenize=r,o;e.eat(/['"]/)}if(i){if(l.singleLineStringErrors)return s;t.tokenize=r}return o}return e.isString=!0,e})(e.current(),t.tokenize),t.tokenize(e,t);for(var i=0;i<a.length;i++)if(e.match(a[i]))return"operator";return e.match(o)?"punctuation":"."==t.lastToken&&e.match(c)?"property":e.match(d)||e.match(_)?"keyword":e.match(m)?"builtin":e.match(/^(self|cls)\b/)?"variable-2":e.match(c)?"def"==t.lastToken||"class"==t.lastToken?"def":"variable":(e.next(),n?null:s)}function b(e){for(;"py"!=w(e).type;)e.scopes.pop();e.scopes.push({offset:w(e).offset+t.indentUnit,type:"py",align:null})}function y(e,t){for(var n=e.indentation();1<t.scopes.length&&w(t).offset>n;){if("py"!=w(t).type)return 1;t.scopes.pop()}return w(t).offset!=n}function g(e,t){e.sol()&&(t.beginningOfLine=!0,t.dedent=!1);var n,r,i,o=t.tokenize(e,t),a=e.current();if(t.beginningOfLine&&"@"==a)return e.match(c,!1)?"meta":p?"operator":s;if(/\S/.test(a)&&(t.beginningOfLine=!1),"variable"!=o&&"builtin"Line truncated
|
||||
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(p){"use strict";p.defineMode("go",function(e){var i,o=e.indentUnit,r={break:!0,case:!0,chan:!0,const:!0,continue:!0,default:!0,defer:!0,else:!0,fallthrough:!0,for:!0,func:!0,go:!0,goto:!0,if:!0,import:!0,interface:!0,map:!0,package:!0,range:!0,return:!0,select:!0,struct:!0,switch:!0,type:!0,var:!0,bool:!0,byte:!0,complex64:!0,complex128:!0,float32:!0,float64:!0,int8:!0,int16:!0,int32:!0,int64:!0,string:!0,uint8:!0,uint16:!0,uint32:!0,uint64:!0,int:!0,uint:!0,uintptr:!0,error:!0,rune:!0,any:!0,comparable:!0},a={true:!0,false:!0,iota:!0,nil:!0,append:!0,cap:!0,close:!0,complex:!0,copy:!0,delete:!0,imag:!0,len:!0,make:!0,new:!0,panic:!0,print:!0,println:!0,real:!0,recover:!0},c=/[+\-*&^%:=<>!|\/]/;function u(e,t){var o,n=e.next();if('"'==n||"'"==n||"`"==n)return t.tokenize=(o=n,function(e,t){for(var n,r=!1,i=!1;null!=(n=e.next());){if(n==o&&!r){i=!0;break}r=!r&&"`"!=o&&"\\"==n}return(i||!r&&"`"!=o)&&(t.tokenize=u),"string"}),t.tokenize(e,t);if(/[\d\.]/.test(n))return"."==n?e.match(/^[0-9_]+([eE][\-+]?[0-9_]+)?/):"0"==n?e.match(/^[xX][0-9a-fA-F_]+/)||e.match(/^[0-7_]+/):e.match(/^[0-9_]*\.?[0-9_]*([eE][\-+]?[0-9_]+)?/),"number";if(/[\[\]{}\(\),;\:\.]/.test(n))return i=n,null;if("/"==n){if(e.eat("*"))return(t.tokenize=l)(e,t);if(e.eat("/"))return e.skipToEnd(),"comment"}if(c.test(n))return e.eatWhile(c),"operator";e.eatWhile(/[\w\$_\xa1-\uffff]/);t=e.current();return r.propertyIsEnumerable(t)?("case"!=t&&"default"!=t||(i="case"),"keyword"):a.propertyIsEnumerable(t)?"atom":"variable"}function l(e,t){for(var n,r=!1;n=e.next();){if("/"==n&&r){t.tokenize=u;break}r="*"==n}return"comment"}function f(e,t,n,r,i){this.indented=e,this.column=t,this.type=n,this.align=r,this.prev=i}function s(e,t,n){e.context=new f(e.indented,t,n,null,e.context)}function d(e){var t;e.context.prev&&(")"!=(t=e.context.type)&&"]"!=t&&"}"!=t||(e.indented=e.context.indented),e.context=e.context.prev)}return{startState:function(e){return{tokenize:null,context:new f((e||0)-o,0,"top",!1),indented:0,startOfLine:!0}},token:function(e,t){var n=t.context;if(e.sol()&&(null==n.align&&(n.align=!1),t.indented=e.indentation(),t.startOfLine=!0,"case"==n.type&&(n.type="}")),e.eatSpace())return null;i=null;var r=(t.tokenize||u)(e,t);return"comment"==r||(null==n.align&&(n.align=!0),"{"==i?s(t,e.column(),"}"):"["==i?s(t,e.column(),"]"):"("==i?s(t,e.column(),")"):"case"==i?n.type="case":("}"==i&&"}"==n.type||i==n.type)&&d(t),t.startOfLine=!1),r},indent:function(e,t){if(e.tokenize!=u&&null!=e.tokenize)return p.Pass;var n=e.context,r=t&&t.charAt(0);if("case"==n.type&&/^(?:case|default)\b/.test(t))return e.context.type="}",n.indented;t=r==n.type;return n.align?n.column+(t?0:1):n.indented+(t?0:o)},electricChars:"{}):",closeBrackets:"()[]{}''\"\"``",fold:"brace",blockCommentStart:"/*",blockCommentEnd:"*/",lineComment:"//"}}),p.defineMIME("text/x-go","go")});;
|
||||
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(e){"use strict";e.defineMode("yaml",function(){var n=new RegExp("\\b(("+["true","false","on","off","yes","no"].join(")|(")+"))$","i");return{token:function(e,i){var t=e.peek(),r=i.escaped;if(i.escaped=!1,"#"==t&&(0==e.pos||/\s/.test(e.string.charAt(e.pos-1))))return e.skipToEnd(),"comment";if(e.match(/^('([^']|\\.)*'?|"([^"]|\\.)*"?)/))return"string";if(i.literal&&e.indentation()>i.keyCol)return e.skipToEnd(),"string";if(i.literal&&(i.literal=!1),e.sol()){if(i.keyCol=0,i.pair=!1,i.pairStart=!1,e.match("---"))return"def";if(e.match("..."))return"def";if(e.match(/\s*-\s+/))return"meta"}if(e.match(/^(\{|\}|\[|\])/))return"{"==t?i.inlinePairs++:"}"==t?i.inlinePairs--:"["==t?i.inlineList++:i.inlineList--,"meta";if(0<i.inlineList&&!r&&","==t)return e.next(),"meta";if(0<i.inlinePairs&&!r&&","==t)return i.keyCol=0,i.pair=!1,i.pairStart=!1,e.next(),"meta";if(i.pairStart){if(e.match(/^\s*(\||\>)\s*/))return i.literal=!0,"meta";if(e.match(/^\s*(\&|\*)[a-z0-9\._-]+\b/i))return"variable-2";if(0==i.inlinePairs&&e.match(/^\s*-?[0-9\.\,]+\s?$/))return"number";if(0<i.inlinePairs&&e.match(/^\s*-?[0-9\.\,]+\s?(?=(,|}))/))return"number";if(e.match(n))return"keyword"}return!i.pair&&e.match(/^\s*(?:[,\[\]{}&*!|>'"%@`][^\s'":]|[^\s,\[\]{}#&*!|>'"%@`])[^#:]*(?=:($|\s))/)?(i.pair=!0,i.keyCol=e.indentation(),"atom"):i.pair&&e.match(/^:\s*/)?(i.pairStart=!0,"meta"):(i.pairStart=!1,i.escaped="\\"==t,e.next(),null)},startState:function(){return{pair:!1,pairStart:!1,keyCol:0,inlinePairs:0,inlineList:0,literal:!1,escaped:!1}},lineComment:"#",fold:"indent"}}),e.defineMIME("text/x-yaml","yaml"),e.defineMIME("text/yaml","yaml")});;
|
||||
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(s){"use strict";s.defineMode("shell",function(){var o={};function e(e,t){for(var n=0;n<t.length;n++)o[t[n]]=e}var t=["true","false"],n=["if","then","do","else","elif","while","until","for","in","esac","fi","fin","fil","done","exit","set","unset","export","function"],r=["ab","awk","bash","beep","cat","cc","cd","chown","chmod","chroot","clear","cp","curl","cut","diff","echo","find","gawk","gcc","get","git","grep","hg","kill","killall","ln","ls","make","mkdir","openssl","mv","nc","nl","node","npm","ping","ps","restart","rm","rmdir","sed","service","sh","shopt","shred","source","sort","sleep","ssh","start","stop","su","sudo","svn","tee","telnet","top","touch","vi","vim","wall","wc","wget","who","write","yes","zsh"];function i(e,t){if(e.eatSpace())return null;var n,r=e.sol(),i=e.next();if("\\"===i)return e.next(),null;if("'"===i||'"'===i||"`"===i)return t.tokens.unshift(f(i,"`"===i?"quote":"string")),l(e,t);if("#"===i)return r&&e.eat("!")?(e.skipToEnd(),"meta"):(e.skipToEnd(),"comment");if("$"===i)return t.tokens.unshift(u),l(e,t);if("+"===i||"="===i)return"operator";if("-"===i)return e.eat("-"),e.eatWhile(/\w/),"attribute";if("<"==i){if(e.match("<<"))return"operator";r=e.match(/^<-?\s*['"]?([^'"]*)['"]?/);if(r)return t.tokens.unshift((n=r[1],function(e,t){return e.sol()&&e.string==n&&t.tokens.shift(),e.skipToEnd(),"string-2"})),"string-2"}if(/\d/.test(i)&&(e.eatWhile(/\d/),e.eol()||!/\w/.test(e.peek())))return"number";e.eatWhile(/[\w-]/);t=e.current();return"="===e.peek()&&/\w+/.test(t)?"def":o.hasOwnProperty(t)?o[t]:null}function f(i,o){var s="("==i?")":"{"==i?"}":i;return function(e,t){for(var n,r=!1;null!=(n=e.next());){if(n===s&&!r){t.tokens.shift();break}if("$"===n&&!r&&"'"!==i&&e.peek()!=s){r=!0,e.backUp(1),t.tokens.unshift(u);break}if(!r&&i!==s&&n===i)return t.tokens.unshift(f(i,o)),l(e,t);if(!r&&/['"]/.test(n)&&!/['"]/.test(i)){t.tokens.unshift(function(n,r){return function(e,t){return t.tokens[0]=f(n,r),e.next(),l(e,t)}}(n,"string")),e.backUp(1);break}r=!r&&"\\"===n}return o}}s.registerHelper("hintWords","shell",t.concat(n,r)),e("atom",t),e("keyword",n),e("builtin",r);var u=function(e,t){1<t.tokens.length&&e.eat("$");var n=e.next();return/['"({]/.test(n)?(t.tokens[0]=f(n,"("==n?"quote":"{"==n?"def":"string"),l(e,t)):(/\d/.test(n)||e.eatWhile(/\w/),t.tokens.shift(),"def")};function l(e,t){return(t.tokens[0]||i)(e,t)}return{startState:function(){return{tokens:[]}},token:l,closeBrackets:"()[]{}''\"\"``",lineComment:"#",fold:"brace"}}),s.defineMIME("text/x-sh","shell"),s.defineMIME("application/x-sh","shell")});;
|
||||
!function(t){"object"==typeof exports&&"object"==typeof module?t(require("../../lib/codemirror"),require("../xml/xml"),require("../meta")):"function"==typeof define&&define.amd?define(["../../lib/codemirror","../xml/xml","../meta"],t):t(CodeMirror)}(function(w){"use strict";w.defineMode("markdown",function(m,d){var g=w.getMode(m,"text/html"),u="null"==g.name;void 0===d.highlightFormatting&&(d.highlightFormatting=!1),void 0===d.maxBlockquoteDepth&&(d.maxBlockquoteDepth=0),void 0===d.taskLists&&(d.taskLists=!1),void 0===d.strikethrough&&(d.strikethrough=!1),void 0===d.emoji&&(d.emoji=!1),void 0===d.fencedCodeBlockHighlighting&&(d.fencedCodeBlockHighlighting=!0),void 0===d.fencedCodeBlockDefaultMode&&(d.fencedCodeBlockDefaultMode="text/plain"),void 0===d.xml&&(d.xml=!0),void 0===d.tokenTypeOverrides&&(d.tokenTypeOverrides={});var t,c={header:"header",code:"comment",quote:"quote",list1:"variable-2",list2:"variable-3",list3:"keyword",hr:"hr",image:"image",imageAltText:"image-alt-text",imageMarker:"image-marker",formatting:"formatting",linkInline:"link",linkEmail:"link",linkText:"link",linkHref:"string",em:"em",strong:"strong",strikethrough:"strikethrough",emoji:"builtin"};for(t in c)c.hasOwnProperty(t)&&d.tokenTypeOverrides[t]&&(c[t]=d.tokenTypeOverrides[t]);var f=/^([*\-_])(?:\s*\1){2,}\s*$/,k=/^(?:[*\-+]|^[0-9]+([.)]))\s+/,F=/^\[(x| )\](?=\s)/i,D=d.allowAtxHeaderWithoutSpace?/^(#+)/:/^(#+)(?: |$)/,p=/^ {0,3}(?:\={1,}|-{2,})\s*$/,i=/^[^#!\[\]*_\\<>` "'(~:]+/,E=/^(~~~+|```+)[ \t]*([\w\/+#-]*)[^\n`]*$/,x=/^\s*\[[^\]]+?\]:.*$/,A=/[!"#$%&'()*+,\-.\/:;<=>?@\[\\\]^_`{|}~\xA1\xA7\xAB\xB6\xB7\xBB\xBF\u037E\u0387\u055A-\u055F\u0589\u058A\u05BE\u05C0\u05C3\u05C6\u05F3\u05F4\u0609\u060A\u060C\u060D\u061B\u061E\u061F\u066A-\u066D\u06D4\u0700-\u070D\u07F7-\u07F9\u0830-\u083E\u085E\u0964\u0965\u0970\u0AF0\u0DF4\u0E4F\u0E5A\u0E5B\u0F04-\u0F12\u0F14\u0F3A-\u0F3D\u0F85\u0FD0-\u0FD4\u0FD9\u0FDA\u104A-\u104F\u10FB\u1360-\u1368\u1400\u166D\u166E\u169B\u169C\u16EB-\u16ED\u1735\u1736\u17D4-\u17D6\u17D8-\u17DA\u1800-\u180A\u1944\u1945\u1A1E\u1A1F\u1AA0-\u1AA6\u1AA8-\u1AAD\u1B5A-\u1B60\u1BFC-\u1BFF\u1C3B-\u1C3F\u1C7E\u1C7F\u1CC0-\u1CC7\u1CD3\u2010-\u2027\u2030-\u2043\u2045-\u2051\u2053-\u205E\u207D\u207E\u208D\u208E\u2308-\u230B\u2329\u232A\u2768-\u2775\u27C5\u27C6\u27E6-\u27EF\u2983-\u2998\u29D8-\u29DB\u29FC\u29FD\u2CF9-\u2CFC\u2CFE\u2CFF\u2D70\u2E00-\u2E2E\u2E30-\u2E42\u3001-\u3003\u3008-\u3011\u3014-\u301F\u3030\u303D\u30A0\u30FB\uA4FE\uA4FF\uA60D-\uA60F\uA673\uA67E\uA6F2-\uA6F7\uA874-\uA877\uA8CE\uA8CF\uA8F8-\uA8FA\uA8FC\uA92E\uA92F\uA95F\uA9C1-\uA9CD\uA9DE\uA9DF\uAA5C-\uAA5F\uAADE\uAADF\uAAF0\uAAF1\uABEB\uFD3E\uFD3F\uFE10-\uFE19\uFE30-\uFE52\uFE54-\uFE61\uFE63\uFE68\uFE6A\uFE6B\uFF01-\uFF03\uFF05-\uFF0A\uFF0C-\uFF0F\uFF1A\uFF1B\uFF1F\uFF20\uFF3B-\uFF3D\uFF3F\uFF5B\uFF5D\uFF5F-\uFF65]|\uD800[\uDD00-\uDD02\uDF9F\uDFD0]|\uD801\uDD6F|\uD802[\uDC57\uDD1F\uDD3F\uDE50-\uDE58\uDE7F\uDEF0-\uDEF6\uDF39-\uDF3F\uDF99-\uDF9C]|\uD804[\uDC47-\uDC4D\uDCBB\uDCBC\uDCBE-\uDCC1\uDD40-\uDD43\uDD74\uDD75\uDDC5-\uDDC9\uDDCD\uDDDB\uDDDD-\uDDDF\uDE38-\uDE3D\uDEA9]|\uD805[\uDCC6\uDDC1-\uDDD7\uDE41-\uDE43\uDF3C-\uDF3E]|\uD809[\uDC70-\uDC74]|\uD81A[\uDE6E\uDE6F\uDEF5\uDF37-\uDF3B\uDF44]|\uD82F\uDC9F|\uD836[\uDE87-\uDE8B]/;function C(t,e,i){return(e.f=e.inline=i)(t,e)}function S(t,e,i){return(e.f=e.block=i)(t,e)}function n(t){var e;return t.linkTitle=!1,t.linkHref=!1,t.linkText=!1,t.em=!1,t.strong=!1,t.strikethrough=!1,t.quote=0,t.indentedCode=!1,t.f==v&&(u?u:"xml"==(e=w.innerMode(g,t.htmlState)).mode.name&&null===e.state.tagStart&&!e.state.context&&e.state.tokenize.isInText)&&(t.f=T,t.block=a,t.htmlState=null),t.trailingSpace=0,t.trailingSpaceNewLine=!1,t.prevLine=t.thisLine,t.thisLine={stream:null},null}function a(t,e){var i=t.column()===e.indentation,n=!(n=e.prevLine.stream)||!/\S/.test(n.string),u=e.indentedCode,a=e.prevLine.hr,r=!1!==e.list,o=(e.listStack[e.listStack.length-1]||0)+3,l=(e.indentedCode=!1,e.indentation);if(null===e.indentationDiff&&(e.indentationDiff=e.indentation,r)){for(e.list=null;l<e.listStack[e.listStack.length-1];)e.listStack.pop(),e.listStack.length?e.indentation=e.listStack[e.listStack.length-1]:e.list=!1;!1!==e.list&&(e.indentationDiff=l-e.listStack[e.listStack.length-1])}var h,s=!(n||a||e.prevLine.header||r&&u||e.prevLine.fencedCodeEnd),a=(!1===e.list||a||n)&&e.indentation<=o&&t.match(f),g=null;return 4<=e.indentationDiff&&(u||e.prevLine.fencedCodeEnd||e.prevLine.header||n)?(t.skipToEnd(),e.indentedCode=!0,c.code):t.eatSpace()?null:i&&e.indentation<=o&&(g=t.match(D))&&g[1].length<=6?(e.quote=0,e.header=g[1].length,e.thisLine.header=!0,d.highlightFormatting&&(e.formatting="header"),e.f=e.inline,L(e)):e.indentation<=o&&t.eat(">")?(e.quote=i?1:e.quote+1,d.highlightFormatting&&(e.formatting="quote"),t.eatSpace(),L(e)):!a&&!e.setext&&i&&e.indentation<=o&&(g=t.match(k))?(u=g[1]?"ol":"ul",e.indentation=l+t.current().length,e.list=!0,e.quote=0,e.listStack.push(e.indentation),e.em=!1,e.strong=!1,e.code=!1,e.strikethrough=!1,d.taskLists&Line truncated
|
||||
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(a){"use strict";function e(e){for(var t;null!=(t=e.next());)if("`"==t&&!e.eat("`"))return"variable-2";return e.backUp(e.current().length-1),e.eatWhile(/\w/)?"variable-2":null}function t(e){return e.eat("@")&&(e.match("session."),e.match("local."),e.match("global.")),e.eat("'")?(e.match(/^.*'/),"variable-2"):e.eat('"')?(e.match(/^.*"/),"variable-2"):e.eat("`")?(e.match(/^.*`/),"variable-2"):e.match(/^[0-9a-zA-Z$\.\_]+/)?"variable-2":null}function r(e){return e.eat("N")?"atom":e.match(/^[a-zA-Z.#!?]/)?"variable-2":null}a.defineMode("sql",function(r,e){var i=e.client||{},n=e.atoms||{false:!0,true:!0,null:!0},s=e.builtin||y(v),o=e.keywords||y(f),l=e.operatorChars||/^[*+\-%<>!=&|~^\/]/,c=e.support||{},m=e.hooks||{},d=e.dateSQL||{date:!0,time:!0,timestamp:!0},u=!1!==e.backslashStringEscapes,p=e.brackets||/^[\{}\(\)\[\]]/,_=e.punctuation||/^[;.,:]/;function g(e,t){var r=e.next();if(m[r]){var a=m[r](e,t);if(!1!==a)return a}return c.hexNumber&&("0"==r&&e.match(/^[xX][0-9a-fA-F]+/)||("x"==r||"X"==r)&&e.match(/^'[0-9a-fA-F]*'/))||c.binaryNumber&&(("b"==r||"B"==r)&&e.match(/^'[01]*'/)||"0"==r&&e.match(/^b[01]+/))?"number":47<r.charCodeAt(0)&&r.charCodeAt(0)<58?(e.match(/^[0-9]*(\.[0-9]+)?([eE][-+]?[0-9]+)?/),c.decimallessFloat&&e.match(/^\.(?!\.)/),"number"):"?"==r&&(e.eatSpace()||e.eol()||e.eat(";"))?"variable-3":"'"==r||'"'==r&&c.doubleQuote?(t.tokenize=h(r),t.tokenize(e,t)):!(c.nCharCast&&("n"==r||"N"==r)||c.charsetCast&&"_"==r&&e.match(/[a-z][a-z0-9]*/i))||"'"!=e.peek()&&'"'!=e.peek()?c.escapeConstant&&("e"==r||"E"==r)&&("'"==e.peek()||'"'==e.peek()&&c.doubleQuote)?(t.tokenize=function(e,t){return(t.tokenize=h(e.next(),!0))(e,t)},"keyword"):c.commentSlashSlash&&"/"==r&&e.eat("/")||c.commentHash&&"#"==r||"-"==r&&e.eat("-")&&(!c.commentSpaceRequired||e.eat(" "))?(e.skipToEnd(),"comment"):"/"==r&&e.eat("*")?(t.tokenize=function a(i){return function(e,t){var r=e.match(/^.*?(\/\*|\*\/)/);return r?"/*"==r[1]?t.tokenize=a(i+1):t.tokenize=1<i?a(i-1):g:e.skipToEnd(),"comment"}}(1),t.tokenize(e,t)):"."==r?c.zerolessFloat&&e.match(/^(?:\d+(?:e[+-]?\d+)?)/i)?"number":e.match(/^\.+/)?null:e.match(/^[\w\d_$#]+/)?"variable-2":void 0:l.test(r)?(e.eatWhile(l),"operator"):p.test(r)?"bracket":_.test(r)?(e.eatWhile(_),"punctuation"):"{"==r&&(e.match(/^( )*(d|D|t|T|ts|TS)( )*'[^']*'( )*}/)||e.match(/^( )*(d|D|t|T|ts|TS)( )*"[^"]*"( )*}/))?"number":(e.eatWhile(/^[_\w\d]/),a=e.current().toLowerCase(),d.hasOwnProperty(a)&&(e.match(/^( )+'[^']*'/)||e.match(/^( )+"[^"]*"/))?"number":n.hasOwnProperty(a)?"atom":s.hasOwnProperty(a)?"type":o.hasOwnProperty(a)?"keyword":i.hasOwnProperty(a)?"builtin":null):"keyword"}function h(i,n){return function(e,t){for(var r,a=!1;null!=(r=e.next());){if(r==i&&!a){t.tokenize=g;break}a=(u||n)&&!a&&"\\"==r}return"string"}}function b(e,t,r){t.context={prev:t.context,indent:e.indentation(),col:e.column(),type:r}}return{startState:function(){return{tokenize:g,context:null}},token:function(e,t){if(e.sol()&&t.context&&null==t.context.align&&(t.context.align=!1),t.tokenize==g&&e.eatSpace())return null;var r=t.tokenize(e,t);if("comment"==r)return r;t.context&&null==t.context.align&&(t.context.align=!0);var a=e.current();return"("==a?b(e,t,")"):"["==a?b(e,t,"]"):t.context&&t.context.type==a&&((e=t).indent=e.context.indent,e.context=e.context.prev),r},indent:function(e,t){e=e.context;if(!e)return a.Pass;t=t.charAt(0)==e.type;return e.align?e.col+(t?0:1):e.indent+(t?0:r.indentUnit)},blockCommentStart:"/*",blockCommentEnd:"*/",lineComment:c.commentSlashSlash?"//":c.commentHash?"#":"--",closeBrackets:"()[]{}''\"\"``",config:e}});var f="alter and as asc between by count create delete desc distinct drop from group having in insert into is join like not on or order select set table union update values where limit ";function y(e){for(var t={},r=e.split(" "),a=0;a<r.length;++a)t[r[a]]=!0;return t}var v="bool boolean bit blob enum long longblob longtext medium mediumblob mediumint mediumtext time timestamp tinyblob tinyint tinytext text bigint int int1 int2 int3 int4 int8 integer float float4 float8 double char varbinary varchar varcharacter precision real date datetime year unsigned signed decimal numeric";a.defineMIME("text/x-sql",{name:"sql",keywords:y(f+"begin"),builtin:y(v),atoms:y("false true null unknown"),dateSQL:y("date time timestamp"),support:y("doubleQuote binaryNumber hexNumber")}),a.defineMIME("text/x-mssql",{name:"sql",client:y("$partition binary_checksum checksum connectionproperty context_info current_request_id error_line error_message error_number error_procedure error_severity error_state formatmessage get_filestream_transaction_context getansinull host_id host_name isnull isnumeric min_active_rowversion newid newsequentialid rowcount_big xact_state object_id"),keywords:y(f+"begin trigger proc view index for add constLine truncated
|
||||
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(e){"use strict";e.defineMode("properties",function(){return{token:function(e,i){var t=e.sol()||i.afterSection,n=e.eol();if(i.afterSection=!1,t&&(i.nextMultiline?(i.inMultiline=!0,i.nextMultiline=!1):i.position="def"),n&&!i.nextMultiline&&(i.inMultiline=!1,i.position="def"),t)for(;e.eatSpace(););n=e.next();return!t||"#"!==n&&"!"!==n&&";"!==n?t&&"["===n?(i.afterSection=!0,e.skipTo("]"),e.eat("]"),"header"):"="===n||":"===n?(i.position="quote",null):("\\"===n&&"quote"===i.position&&e.eol()&&(i.nextMultiline=!0),i.position):(i.position="comment",e.skipToEnd(),"comment")},startState:function(){return{position:"def",nextMultiline:!1,inMultiline:!1,afterSection:!1}}}}),e.defineMIME("text/x-properties","properties"),e.defineMIME("text/x-ini","properties")});;
|
||||
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(e){"use strict";e.defineMode("toml",function(){return{startState:function(){return{inString:!1,stringType:"",lhs:!0,inArray:0}},token:function(e,t){if(t.inString||'"'!=e.peek()&&"'"!=e.peek()||(t.stringType=e.peek(),e.next(),t.inString=!0),e.sol()&&0===t.inArray&&(t.lhs=!0),t.inString){for(;t.inString&&!e.eol();)e.peek()===t.stringType?(e.next(),t.inString=!1):"\\"===e.peek()?(e.next(),e.next()):e.match(/^.[^\\\"\']*/);return t.lhs?"property string":"string"}return t.inArray&&"]"===e.peek()?(e.next(),t.inArray--,"bracket"):t.lhs&&"["===e.peek()&&e.skipTo("]")?(e.next(),"]"===e.peek()&&e.next(),"atom"):"#"===e.peek()?(e.skipToEnd(),"comment"):e.eatSpace()?null:t.lhs&&e.eatWhile(function(e){return"="!=e&&" "!=e})?"property":t.lhs&&"="===e.peek()?(e.next(),t.lhs=!1,null):(t.lhs||!e.match(/^\d\d\d\d[\d\-\:\.T]*Z/))&&(t.lhs||!e.match("true")&&!e.match("false"))?t.lhs||"["!==e.peek()?!t.lhs&&e.match(/^\-?\d+(?:\.\d+)?/)?"number":(e.eatSpace()||e.next(),null):(t.inArray++,e.next(),"bracket"):"atom"}}}),e.defineMIME("text/x-toml","toml")});;
|
||||
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(e){"use strict";e.defineMode("nginx",function(e){function _(e){for(var _={},t=e.split(" "),i=0;i<t.length;++i)_[t[i]]=!0;return _}var s,i=_("break return rewrite set accept_mutex accept_mutex_delay access_log add_after_body add_before_body add_header addition_types aio alias allow ancient_browser ancient_browser_value auth_basic auth_basic_user_file auth_http auth_http_header auth_http_timeout autoindex autoindex_exact_size autoindex_localtime charset charset_types client_body_buffer_size client_body_in_file_only client_body_in_single_buffer client_body_temp_path client_body_timeout client_header_buffer_size client_header_timeout client_max_body_size connection_pool_size create_full_put_path daemon dav_access dav_methods debug_connection debug_points default_type degradation degrade deny devpoll_changes devpoll_events directio directio_alignment empty_gif env epoll_events error_log eventport_events expires fastcgi_bind fastcgi_buffer_size fastcgi_buffers fastcgi_busy_buffers_size fastcgi_cache fastcgi_cache_key fastcgi_cache_methods fastcgi_cache_min_uses fastcgi_cache_path fastcgi_cache_use_stale fastcgi_cache_valid fastcgi_catch_stderr fastcgi_connect_timeout fastcgi_hide_header fastcgi_ignore_client_abort fastcgi_ignore_headers fastcgi_index fastcgi_intercept_errors fastcgi_max_temp_file_size fastcgi_next_upstream fastcgi_param fastcgi_pass_header fastcgi_pass_request_body fastcgi_pass_request_headers fastcgi_read_timeout fastcgi_send_lowat fastcgi_send_timeout fastcgi_split_path_info fastcgi_store fastcgi_store_access fastcgi_temp_file_write_size fastcgi_temp_path fastcgi_upstream_fail_timeout fastcgi_upstream_max_fails flv geoip_city geoip_country google_perftools_profiles gzip gzip_buffers gzip_comp_level gzip_disable gzip_hash gzip_http_version gzip_min_length gzip_no_buffer gzip_proxied gzip_static gzip_types gzip_vary gzip_window if_modified_since ignore_invalid_headers image_filter image_filter_buffer image_filter_jpeg_quality image_filter_transparency imap_auth imap_capabilities imap_client_buffer index ip_hash keepalive_requests keepalive_timeout kqueue_changes kqueue_events large_client_header_buffers limit_conn limit_conn_log_level limit_rate limit_rate_after limit_req limit_req_log_level limit_req_zone limit_zone lingering_time lingering_timeout lock_file log_format log_not_found log_subrequest map_hash_bucket_size map_hash_max_size master_process memcached_bind memcached_buffer_size memcached_connect_timeout memcached_next_upstream memcached_read_timeout memcached_send_timeout memcached_upstream_fail_timeout memcached_upstream_max_fails merge_slashes min_delete_depth modern_browser modern_browser_value msie_padding msie_refresh multi_accept open_file_cache open_file_cache_errors open_file_cache_events open_file_cache_min_uses open_file_cache_valid open_log_file_cache output_buffers override_charset perl perl_modules perl_require perl_set pid pop3_auth pop3_capabilities port_in_redirect postpone_gzipping postpone_output protocol proxy proxy_bind proxy_buffer proxy_buffer_size proxy_buffering proxy_buffers proxy_busy_buffers_size proxy_cache proxy_cache_key proxy_cache_methods proxy_cache_min_uses proxy_cache_path proxy_cache_use_stale proxy_cache_valid proxy_connect_timeout proxy_headers_hash_bucket_size proxy_headers_hash_max_size proxy_hide_header proxy_ignore_client_abort proxy_ignore_headers proxy_intercept_errors proxy_max_temp_file_size proxy_method proxy_next_upstream proxy_pass_error_message proxy_pass_header proxy_pass_request_body proxy_pass_request_headers proxy_read_timeout proxy_redirect proxy_send_lowat proxy_send_timeout proxy_set_body proxy_set_header proxy_ssl_session_reuse proxy_store proxy_store_access proxy_temp_file_write_size proxy_temp_path proxy_timeout proxy_upstream_fail_timeout proxy_upstream_max_fails random_index read_ahead real_ip_header recursive_error_pages request_pool_size reset_timedout_connection resolver resolver_timeout rewrite_log rtsig_overflow_events rtsig_overflow_test rtsig_overflow_threshold rtsig_signo satisfy secure_link_secret send_lowat send_timeout sendfile sendfile_max_chunk server_name_in_redirect server_names_hash_bucket_size server_names_hash_max_size server_tokens set_real_ip_from smtp_auth smtp_capabilities smtp_client_buffer smtp_greeting_delay so_keepalive source_charset ssi ssi_ignore_recycled_buffers ssi_min_file_chunk ssi_silent_errors ssi_types ssi_value_length ssl ssl_certificate ssl_certificate_key ssl_ciphers ssl_client_certificate ssl_crl ssl_dhparam ssl_engine ssl_prefer_server_ciphers ssl_protocols ssl_session_cache ssl_session_timeout ssl_verify_client ssl_verify_depth starttls stub_status sub_filter sub_filter_once sub_filter_types tcp_nodelay tcp_nopush thread_stack_size timeout timer_resolution types_hash_bucket_size types_hash_maLine truncated
|
||||
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror"),require("../../addon/mode/simple")):"function"==typeof define&&define.amd?define(["../../lib/codemirror","../../addon/mode/simple"],e):e(CodeMirror)}(function(e){"use strict";var n="from",t=new RegExp("^(\\s*)\\b(from)\\b","i"),r=["run","cmd","entrypoint","shell"],o=new RegExp("^(\\s*)("+r.join("|")+")(\\s+\\[)","i"),l="expose",s=new RegExp("^(\\s*)("+l+")(\\s+)","i"),n="("+[n,l].concat(r).concat(["arg","from","maintainer","label","env","add","copy","volume","user","workdir","onbuild","stopsignal","healthcheck","shell"]).join("|")+")",l=new RegExp("^(\\s*)"+n+"(\\s*)(#.*)?$","i"),r=new RegExp("^(\\s*)"+n+"(\\s+)","i");e.defineSimpleMode("dockerfile",{start:[{regex:/^\s*#.*$/,sol:!0,token:"comment"},{regex:t,token:[null,"keyword"],sol:!0,next:"from"},{regex:l,token:[null,"keyword",null,"error"],sol:!0},{regex:o,token:[null,"keyword",null],sol:!0,next:"array"},{regex:s,token:[null,"keyword",null],sol:!0,next:"expose"},{regex:r,token:[null,"keyword",null],sol:!0,next:"arguments"},{regex:/./,token:null}],from:[{regex:/\s*$/,token:null,next:"start"},{regex:/(\s*)(#.*)$/,token:[null,"error"],next:"start"},{regex:/(\s*\S+\s+)(as)/i,token:[null,"keyword"],next:"start"},{token:null,next:"start"}],single:[{regex:/(?:[^\\']|\\.)/,token:"string"},{regex:/'/,token:"string",pop:!0}],double:[{regex:/(?:[^\\"]|\\.)/,token:"string"},{regex:/"/,token:"string",pop:!0}],array:[{regex:/\]/,token:null,next:"start"},{regex:/"(?:[^\\"]|\\.)*"?/,token:"string"}],expose:[{regex:/\d+$/,token:"number",next:"start"},{regex:/[^\d]+$/,token:null,next:"start"},{regex:/\d+/,token:"number"},{regex:/[^\d]+/,token:null},{token:null,next:"start"}],arguments:[{regex:/^\s*#.*$/,sol:!0,token:"comment"},{regex:/"(?:[^\\"]|\\.)*"?$/,token:"string",next:"start"},{regex:/"/,token:"string",push:"double"},{regex:/'(?:[^\\']|\\.)*'?$/,token:"string",next:"start"},{regex:/'/,token:"string",push:"single"},{regex:/[^#"']+[\\`]$/,token:null},{regex:/[^#"']+$/,token:null,next:"start"},{regex:/[^#"']+/,token:null},{token:null,next:"start"}],meta:{lineComment:"#"}}),e.defineMIME("text/x-dockerfile","dockerfile")});;
|
||||
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(e){"use strict";e.defineMode("lua",function(e,t){var n=e.indentUnit;function a(e){return new RegExp("^(?:"+e.join("|")+")$","i")}var r=a(t.specials||[]),o=a(["_G","_VERSION","assert","collectgarbage","dofile","error","getfenv","getmetatable","ipairs","load","loadfile","loadstring","module","next","pairs","pcall","print","rawequal","rawget","rawset","require","select","setfenv","setmetatable","tonumber","tostring","type","unpack","xpcall","coroutine.create","coroutine.resume","coroutine.running","coroutine.status","coroutine.wrap","coroutine.yield","debug.debug","debug.getfenv","debug.gethook","debug.getinfo","debug.getlocal","debug.getmetatable","debug.getregistry","debug.getupvalue","debug.setfenv","debug.sethook","debug.setlocal","debug.setmetatable","debug.setupvalue","debug.traceback","close","flush","lines","read","seek","setvbuf","write","io.close","io.flush","io.input","io.lines","io.open","io.output","io.popen","io.read","io.stderr","io.stdin","io.stdout","io.tmpfile","io.type","io.write","math.abs","math.acos","math.asin","math.atan","math.atan2","math.ceil","math.cos","math.cosh","math.deg","math.exp","math.floor","math.fmod","math.frexp","math.huge","math.ldexp","math.log","math.log10","math.max","math.min","math.modf","math.pi","math.pow","math.rad","math.random","math.randomseed","math.sin","math.sinh","math.sqrt","math.tan","math.tanh","os.clock","os.date","os.difftime","os.execute","os.exit","os.getenv","os.remove","os.rename","os.setlocale","os.time","os.tmpname","package.cpath","package.loaded","package.loaders","package.loadlib","package.path","package.preload","package.seeall","string.byte","string.char","string.dump","string.find","string.format","string.gmatch","string.gsub","string.len","string.lower","string.match","string.rep","string.reverse","string.sub","string.upper","table.concat","table.insert","table.maxn","table.remove","table.sort"]),i=a(["and","break","elseif","false","nil","not","or","return","true","function","end","if","then","else","do","while","repeat","until","for","in","local"]),l=a(["function","if","repeat","do","\\(","{"]),s=a(["end","until","\\)","}"]),u=new RegExp("^(?:"+["end","until","\\)","}","else","elseif"].join("|")+")","i");function c(e){for(var t=0;e.eat("=");)++t;return e.eat("["),t}function m(e,t){var r,n=e.next();return"-"==n&&e.eat("-")?e.eat("[")&&e.eat("[")?(t.cur=d(c(e),"comment"))(e,t):(e.skipToEnd(),"comment"):'"'==n||"'"==n?(t.cur=(r=n,function(e,t){for(var n,a=!1;null!=(n=e.next())&&(n!=r||a);)a=!a&&"\\"==n;return a||(t.cur=m),"string"}))(e,t):"["==n&&/[\[=]/.test(e.peek())?(t.cur=d(c(e),"string"))(e,t):/\d/.test(n)?(e.eatWhile(/[\w.%]/),"number"):/[\w_]/.test(n)?(e.eatWhile(/[\w\\\-_.]/),"variable"):null}function d(r,o){return function(e,t){for(var n,a=null;null!=(n=e.next());)if(null==a)"]"==n&&(a=0);else if("="==n)++a;else{if("]"==n&&a==r){t.cur=m;break}a=null}return o}}return{startState:function(e){return{basecol:e||0,indentDepth:0,cur:m}},token:function(e,t){if(e.eatSpace())return null;var n=t.cur(e,t),e=e.current();return"variable"==n&&(i.test(e)?n="keyword":o.test(e)?n="builtin":r.test(e)&&(n="variable-2")),"comment"!=n&&"string"!=n&&(l.test(e)?++t.indentDepth:s.test(e)&&--t.indentDepth),n},indent:function(e,t){t=u.test(t);return e.basecol+n*(e.indentDepth-(t?1:0))},electricInput:/^\s*(?:end|until|else|\)|\})$/,lineComment:"--",blockCommentStart:"--[[",blockCommentEnd:"]]"}}),e.defineMIME("text/x-lua","lua")});;
|
||||
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror"),require("../../addon/mode/simple")):"function"==typeof define&&define.amd?define(["../../lib/codemirror","../../addon/mode/simple"],e):e(CodeMirror)}(function(e){"use strict";e.defineSimpleMode("rust",{start:[{regex:/b?"/,token:"string",next:"string"},{regex:/b?r"/,token:"string",next:"string_raw"},{regex:/b?r#+"/,token:"string",next:"string_raw_hash"},{regex:/'(?:[^'\\]|\\(?:[nrt0'"]|x[\da-fA-F]{2}|u\{[\da-fA-F]{6}\}))'/,token:"string-2"},{regex:/b'(?:[^']|\\(?:['\\nrt0]|x[\da-fA-F]{2}))'/,token:"string-2"},{regex:/(?:(?:[0-9][0-9_]*)(?:(?:[Ee][+-]?[0-9_]+)|\.[0-9_]+(?:[Ee][+-]?[0-9_]+)?)(?:f32|f64)?)|(?:0(?:b[01_]+|(?:o[0-7_]+)|(?:x[0-9a-fA-F_]+))|(?:[0-9][0-9_]*))(?:u8|u16|u32|u64|i8|i16|i32|i64|isize|usize)?/,token:"number"},{regex:/(let(?:\s+mut)?|fn|enum|mod|struct|type|union)(\s+)([a-zA-Z_][a-zA-Z0-9_]*)/,token:["keyword",null,"def"]},{regex:/(?:abstract|alignof|as|async|await|box|break|continue|const|crate|do|dyn|else|enum|extern|fn|for|final|if|impl|in|loop|macro|match|mod|move|offsetof|override|priv|proc|pub|pure|ref|return|self|sizeof|static|struct|super|trait|type|typeof|union|unsafe|unsized|use|virtual|where|while|yield)\b/,token:"keyword"},{regex:/\b(?:Self|isize|usize|char|bool|u8|u16|u32|u64|f16|f32|f64|i8|i16|i32|i64|str|Option)\b/,token:"atom"},{regex:/\b(?:true|false|Some|None|Ok|Err)\b/,token:"builtin"},{regex:/\b(fn)(\s+)([a-zA-Z_][a-zA-Z0-9_]*)/,token:["keyword",null,"def"]},{regex:/#!?\[.*\]/,token:"meta"},{regex:/\/\/.*/,token:"comment"},{regex:/\/\*/,token:"comment",next:"comment"},{regex:/[-+\/*=<>!]+/,token:"operator"},{regex:/[a-zA-Z_]\w*!/,token:"variable-3"},{regex:/[a-zA-Z_]\w*/,token:"variable"},{regex:/[\{\[\(]/,indent:!0},{regex:/[\}\]\)]/,dedent:!0}],string:[{regex:/"/,token:"string",next:"start"},{regex:/(?:[^\\"]|\\(?:.|$))*/,token:"string"}],string_raw:[{regex:/"/,token:"string",next:"start"},{regex:/[^"]*/,token:"string"}],string_raw_hash:[{regex:/"#+/,token:"string",next:"start"},{regex:/(?:[^"]|"(?!#))*/,token:"string"}],comment:[{regex:/.*?\*\//,token:"comment",next:"start"},{regex:/.*/,token:"comment"}],meta:{dontIndentStates:["comment"],electricInput:/^\s*\}$/,blockCommentStart:"/*",blockCommentEnd:"*/",lineComment:"//",fold:"brace"}}),e.defineMIME("text/x-rustsrc","rust"),e.defineMIME("text/rust","rust")});;
|
||||
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(e){"use strict";e.defineMode("diff",function(){var o={"+":"positive","-":"negative","@":"meta"};return{token:function(e){var r=e.string.search(/[\t ]+?$/);if(!e.sol()||0===r)return e.skipToEnd(),("error "+(o[e.string.charAt(0)]||"")).replace(/ $/,"");var i=o[e.peek()]||e.skipToEnd();return-1===r?e.skipToEnd():e.pos=r,i}}}),e.defineMIME("text/x-diff","diff")});;
|
||||
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(o){"use strict";function e(e){for(var t={},n=0,r=e.length;n<r;++n)t[e[n]]=!0;return t}var t=["alias","and","BEGIN","begin","break","case","class","def","defined?","do","else","elsif","END","end","ensure","false","for","if","in","module","next","not","or","redo","rescue","retry","return","self","super","then","true","undef","unless","until","when","while","yield","nil","raise","throw","catch","fail","loop","callcc","caller","lambda","proc","public","protected","private","require","load","require_relative","extend","autoload","__END__","__FILE__","__LINE__","__dir__"],a=e(t),u=e(["def","class","case","for","while","until","module","catch","loop","proc","begin"]),p=e(["end","until"]),k={"[":"]","{":"}","(":")"},m={"]":"[","}":"{",")":"("};o.defineMode("ruby",function(i){var l;function f(e,t,n){return n.tokenize.push(e),e(t,n)}function d(e,t){if(e.sol()&&e.match("=begin")&&e.eol())return t.tokenize.push(c),"comment";if(e.eatSpace())return null;var n,r,i,o,a,u=e.next();if("`"==u||"'"==u||'"'==u)return f(s(u,"string",'"'==u||"`"==u),e,t);if("/"==u)return function(e){var t,n=e.pos,r=0,i=!1,o=!1;for(;null!=(t=e.next());)if(o)o=!1;else{if(-1<"[{(".indexOf(t))r++;else if(-1<"]})".indexOf(t)){if(--r<0)break}else if("/"==t&&0==r){i=!0;break}o="\\"==t}return e.backUp(e.pos-n),i}(e)?f(s(u,"string-2",!0),e,t):"operator";if("%"==u)return i="string",n=!0,e.eat("s")?i="atom":e.eat(/[WQ]/)?i="string":e.eat(/[r]/)?i="string-2":e.eat(/[wxq]/)&&(n=!(i="string")),(r=e.eat(/[^\w\s=]/))?f(s(r=k.propertyIsEnumerable(r)?k[r]:r,i,n,!0),e,t):"operator";if("#"==u)return e.skipToEnd(),"comment";if("<"==u&&(r=e.match(/^<([-~])[\`\"\']?([a-zA-Z_?]\w*)[\`\"\']?(?:;|$)/)))return f((o=r[2],a=r[1],function(e,t){return a&&e.eatSpace(),e.match(o)?t.tokenize.pop():e.skipToEnd(),"string"}),e,t);if("0"==u)return e.eat("x")?e.eatWhile(/[\da-fA-F]/):e.eat("b")?e.eatWhile(/[01]/):e.eatWhile(/[0-7]/),"number";if(/\d/.test(u))return e.match(/^[\d_]*(?:\.[\d_]+)?(?:[eE][+\-]?[\d_]+)?/),"number";if("?"!=u)return":"==u?e.eat("'")?f(s("'","atom",!1),e,t):e.eat('"')?f(s('"',"atom",!0),e,t):e.eat(/[\<\>]/)?(e.eat(/[\<\>]/),"atom"):e.eat(/[\+\-\*\/\&\|\:\!]/)?"atom":e.eat(/[a-zA-Z$@_\xa1-\uffff]/)?(e.eatWhile(/[\w$\xa1-\uffff]/),e.eat(/[\?\!\=]/),"atom"):"operator":"@"==u&&e.match(/^@?[a-zA-Z_\xa1-\uffff]/)?(e.eat("@"),e.eatWhile(/[\w\xa1-\uffff]/),"variable-2"):"$"==u?(e.eat(/[a-zA-Z_]/)?e.eatWhile(/[\w]/):e.eat(/\d/)?e.eat(/\d/):e.next(),"variable-3"):/[a-zA-Z_\xa1-\uffff]/.test(u)?(e.eatWhile(/[\w\xa1-\uffff]/),e.eat(/[\?\!]/),e.eat(":")?"atom":"ident"):"|"!=u||!t.varList&&"{"!=t.lastTok&&"do"!=t.lastTok?/[\(\)\[\]{}\\;]/.test(u)?(l=u,null):"-"==u&&e.eat(">")?"arrow":/[=+\-\/*:\.^%<>~|]/.test(u)?(i=e.eatWhile(/[=+\-\/*:\.^%<>~|]/),"."!=u||i||(l="."),"operator"):null:(l="|",null);for(;e.match(/^\\[CM]-/););return e.eat("\\")?e.eatWhile(/\w/):e.next(),"string"}function s(i,o,a,u){return function(e,t){var n,r=!1;for("read-quoted-paused"===t.context.type&&(t.context=t.context.prev,e.eat("}"));null!=(n=e.next());){if(n==i&&(u||!r)){t.tokenize.pop();break}if(a&&"#"==n&&!r){if(e.eat("{")){"}"==i&&(t.context={prev:t.context,type:"read-quoted-paused"}),t.tokenize.push(function n(r){return r=r||1,function(e,t){if("}"==e.peek()){if(1==r)return t.tokenize.pop(),t.tokenize[t.tokenize.length-1](e,t);t.tokenize[t.tokenize.length-1]=n(r-1)}else"{"==e.peek()&&(t.tokenize[t.tokenize.length-1]=n(r+1));return d(e,t)}}());break}if(/[@\$]/.test(e.peek())){t.tokenize.push(function(){var n=!1;return function(e,t){return n?(t.tokenize.pop(),t.tokenize[t.tokenize.length-1](e,t)):(n=!0,d(e,t))}}());break}}r=!r&&"\\"==n}return o}}function c(e,t){return e.sol()&&e.match("=end")&&e.eol()&&t.tokenize.pop(),e.skipToEnd(),"comment"}return{startState:function(){return{tokenize:[d],indented:0,context:{type:"top",indented:-i.indentUnit},continuedLine:!1,lastTok:null,varList:!1}},token:function(e,t){l=null,e.sol()&&(t.indented=e.indentation());var n,r,i=t.tokenize[t.tokenize.length-1](e,t),o=l;return"ident"==i&&(r=e.current(),"keyword"==(i="."==t.lastTok?"property":a.propertyIsEnumerable(e.current())?"keyword":/^[A-Z]/.test(r)?"tag":"def"==t.lastTok||"class"==t.lastTok||t.varList?"def":"variable")&&(u.propertyIsEnumerable(o=r)?n="indent":p.propertyIsEnumerable(r)?n="dedent":(("if"==r||"unless"==r)&&e.column()==e.indentation()||"do"==r&&t.context.indented<t.indented)&&(n="indent"))),(l||i&&"comment"!=i)&&(t.lastTok=o),"|"==l&&(t.varList=!t.varList),"indent"==n||/[\(\[\{]/.test(l)?t.context={prev:t.context,type:l||i,indented:t.indented}:("dedent"==n||/[\)\]\}]/.test(l))&&t.context.prev&&(t.context=t.context.prev),e.eol()&&(t.continuedLine="\\"==l||"operator"==i),i},indent:function(e,t){if(e.tokenize[e.tokenize.length-1]!=d)return o.Pass;var n=t&&t.charAt(0),r=e.context,n=r.type==m[n]||"keyword"==r.type&Line truncated
|
||||
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(e){"use strict";function R(e,t){return e.string.charAt(e.pos+(t||0))}function c(e,t){var r;return t?(r=e.pos-t,e.string.substr(0<=r?r:0,t)):e.string.substr(0,e.pos-1)}function p(e,t){var r=e.string.length,n=r-e.pos+1;return e.string.substr(e.pos,t&&t<r?t:n)}function _(e,t){var r,t=e.pos+t;t<=0?e.pos=0:t>=(r=e.string.length-1)?e.pos=r:e.pos=t}e.defineMode("perl",function(){var a={"->":4,"++":4,"--":4,"**":4,"=~":4,"!~":4,"*":4,"/":4,"%":4,x:4,"+":4,"-":4,".":4,"<<":4,">>":4,"<":4,">":4,"<=":4,">=":4,lt:4,gt:4,le:4,ge:4,"==":4,"!=":4,"<=>":4,eq:4,ne:4,cmp:4,"~~":4,"&":4,"|":4,"^":4,"&&":4,"||":4,"//":4,"..":4,"...":4,"?":4,":":4,"=":4,"+=":4,"-=":4,"*=":4,",":4,"=>":4,"::":4,not:4,and:4,or:4,xor:4,BEGIN:[5,1],END:[5,1],PRINT:[5,1],PRINTF:[5,1],GETC:[5,1],READ:[5,1],READLINE:[5,1],DESTROY:[5,1],TIE:[5,1],TIEHANDLE:[5,1],UNTIE:[5,1],STDIN:5,STDIN_TOP:5,STDOUT:5,STDOUT_TOP:5,STDERR:5,STDERR_TOP:5,$ARG:5,$_:5,"@ARG":5,"@_":5,$LIST_SEPARATOR:5,'$"':5,$PROCESS_ID:5,$PID:5,$$:5,$REAL_GROUP_ID:5,$GID:5,"$(":5,$EFFECTIVE_GROUP_ID:5,$EGID:5,"$)":5,$PROGRAM_NAME:5,$0:5,$SUBSCRIPT_SEPARATOR:5,$SUBSEP:5,"$;":5,$REAL_USER_ID:5,$UID:5,"$<":5,$EFFECTIVE_USER_ID:5,$EUID:5,"$>":5,$a:5,$b:5,$COMPILING:5,"$^C":5,$DEBUGGING:5,"$^D":5,"${^ENCODING}":5,$ENV:5,"%ENV":5,$SYSTEM_FD_MAX:5,"$^F":5,"@F":5,"${^GLOBAL_PHASE}":5,"$^H":5,"%^H":5,"@INC":5,"%INC":5,$INPLACE_EDIT:5,"$^I":5,"$^M":5,$OSNAME:5,"$^O":5,"${^OPEN}":5,$PERLDB:5,"$^P":5,$SIG:5,"%SIG":5,$BASETIME:5,"$^T":5,"${^TAINT}":5,"${^UNICODE}":5,"${^UTF8CACHE}":5,"${^UTF8LOCALE}":5,$PERL_VERSION:5,"$^V":5,"${^WIN32_SLOPPY_STAT}":5,$EXECUTABLE_NAME:5,"$^X":5,$1:5,$MATCH:5,"$&":5,"${^MATCH}":5,$PREMATCH:5,"$`":5,"${^PREMATCH}":5,$POSTMATCH:5,"$'":5,"${^POSTMATCH}":5,$LAST_PAREN_MATCH:5,"$+":5,$LAST_SUBMATCH_RESULT:5,"$^N":5,"@LAST_MATCH_END":5,"@+":5,"%LAST_PAREN_MATCH":5,"%+":5,"@LAST_MATCH_START":5,"@-":5,"%LAST_MATCH_START":5,"%-":5,$LAST_REGEXP_CODE_RESULT:5,"$^R":5,"${^RE_DEBUG_FLAGS}":5,"${^RE_TRIE_MAXBUF}":5,$ARGV:5,"@ARGV":5,ARGV:5,ARGVOUT:5,$OUTPUT_FIELD_SEPARATOR:5,$OFS:5,"$,":5,$INPUT_LINE_NUMBER:5,$NR:5,"$.":5,$INPUT_RECORD_SEPARATOR:5,$RS:5,"$/":5,$OUTPUT_RECORD_SEPARATOR:5,$ORS:5,"$\\":5,$OUTPUT_AUTOFLUSH:5,"$|":5,$ACCUMULATOR:5,"$^A":5,$FORMAT_FORMFEED:5,"$^L":5,$FORMAT_PAGE_NUMBER:5,"$%":5,$FORMAT_LINES_LEFT:5,"$-":5,$FORMAT_LINE_BREAK_CHARACTERS:5,"$:":5,$FORMAT_LINES_PER_PAGE:5,"$=":5,$FORMAT_TOP_NAME:5,"$^":5,$FORMAT_NAME:5,"$~":5,"${^CHILD_ERROR_NATIVE}":5,$EXTENDED_OS_ERROR:5,"$^E":5,$EXCEPTIONS_BEING_CAUGHT:5,"$^S":5,$WARNING:5,"$^W":5,"${^WARNING_BITS}":5,$OS_ERROR:5,$ERRNO:5,"$!":5,"%OS_ERROR":5,"%ERRNO":5,"%!":5,$CHILD_ERROR:5,"$?":5,$EVAL_ERROR:5,"$@":5,$OFMT:5,"$#":5,"$*":5,$ARRAY_BASE:5,"$[":5,$OLD_PERL_VERSION:5,"$]":5,if:[1,1],elsif:[1,1],else:[1,1],while:[1,1],unless:[1,1],for:[1,1],foreach:[1,1],abs:1,accept:1,alarm:1,atan2:1,bind:1,binmode:1,bless:1,bootstrap:1,break:1,caller:1,chdir:1,chmod:1,chomp:1,chop:1,chown:1,chr:1,chroot:1,close:1,closedir:1,connect:1,continue:[1,1],cos:1,crypt:1,dbmclose:1,dbmopen:1,default:1,defined:1,delete:1,die:1,do:1,dump:1,each:1,endgrent:1,endhostent:1,endnetent:1,endprotoent:1,endpwent:1,endservent:1,eof:1,eval:1,exec:1,exists:1,exit:1,exp:1,fcntl:1,fileno:1,flock:1,fork:1,format:1,formline:1,getc:1,getgrent:1,getgrgid:1,getgrnam:1,gethostbyaddr:1,gethostbyname:1,gethostent:1,getlogin:1,getnetbyaddr:1,getnetbyname:1,getnetent:1,getpeername:1,getpgrp:1,getppid:1,getpriority:1,getprotobyname:1,getprotobynumber:1,getprotoent:1,getpwent:1,getpwnam:1,getpwuid:1,getservbyname:1,getservbyport:1,getservent:1,getsockname:1,getsockopt:1,given:1,glob:1,gmtime:1,goto:1,grep:1,hex:1,import:1,index:1,int:1,ioctl:1,join:1,keys:1,kill:1,last:1,lc:1,lcfirst:1,length:1,link:1,listen:1,local:2,localtime:1,lock:1,log:1,lstat:1,m:null,map:1,mkdir:1,msgctl:1,msgget:1,msgrcv:1,msgsnd:1,my:2,new:1,next:1,no:1,oct:1,open:1,opendir:1,ord:1,our:2,pack:1,package:1,pipe:1,pop:1,pos:1,print:1,printf:1,prototype:1,push:1,q:null,qq:null,qr:null,quotemeta:null,qw:null,qx:null,rand:1,read:1,readdir:1,readline:1,readlink:1,readpipe:1,recv:1,redo:1,ref:1,rename:1,require:1,reset:1,return:1,reverse:1,rewinddir:1,rindex:1,rmdir:1,s:null,say:1,scalar:1,seek:1,seekdir:1,select:1,semctl:1,semget:1,semop:1,send:1,setgrent:1,sethostent:1,setnetent:1,setpgrp:1,setpriority:1,setprotoent:1,setpwent:1,setservent:1,setsockopt:1,shift:1,shmctl:1,shmget:1,shmread:1,shmwrite:1,shutdown:1,sin:1,sleep:1,socket:1,socketpair:1,sort:1,splice:1,split:1,sprintf:1,sqrt:1,srand:1,stat:1,state:1,study:1,sub:1,substr:1,symlink:1,syscall:1,sysopen:1,sysread:1,sysseek:1,system:1,syswrite:1,tell:1,telldir:1,tie:1,tied:1,time:1,times:1,tr:null,truncate:1,uc:1,ucfirst:1,umask:1,undef:1,unlink:1,unpack:1,unshift:1,untie:1,use:1,utime:1,values:1,vec:1,wait:1,waitpid:1,wantarray:1,warn:1,whenLine truncated
|
||||
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(e){"use strict";e.defineMode("cmake",function(){var i=/({)?[a-zA-Z0-9_]+(})?/;function r(e,n){for(var t,i,r=!1;!e.eol()&&(t=e.next())!=n.pending;){if("$"===t&&"\\"!=i&&'"'==n.pending){r=!0;break}i=t}return r&&e.backUp(1),t==n.pending?n.continueString=!1:n.continueString=!0,"string"}return{startState:function(){var e={inDefinition:!1,inInclude:!1,continueString:!1,pending:!1};return e},token:function(e,n){return e.eatSpace()?null:(n=n,"$"===(t=(e=e).next())?e.match(i)?"variable-2":"variable":n.continueString?(e.backUp(1),r(e,n)):e.match(/(\s+)?\w+\(/)||e.match(/(\s+)?\w+\ \(/)?(e.backUp(1),"def"):"#"==t?(e.skipToEnd(),"comment"):"'"==t||'"'==t?(n.pending=t,r(e,n)):"("==t||")"==t?"bracket":t.match(/[0-9]/)?"number":(e.eatWhile(/[\w-]/),null));var t}}}),e.defineMIME("text/x-cmake","cmake")});;
|
||||
@@ -0,0 +1,326 @@
|
||||
// SQLite editor of the file manager: the agent opens the database and runs
|
||||
// the queries, so a database in use keeps its locks. Tables and views in
|
||||
// tabs, rows a page at a time; a click on a value edits it (Enter saves,
|
||||
// Esc cancels, Shift+Enter a new line, the ∅ button makes it NULL). Rows are
|
||||
// added and deleted, and a tab runs any SQL.
|
||||
(function () {
|
||||
'use strict';
|
||||
var PAGE = 200;
|
||||
var esc = function (s) { return String(s).replace(/[&<>"]/g, function (c) { return {'&': '&', '<': '<', '>': '>', '"': '"'}[c]; }); };
|
||||
|
||||
function cellHTML(c) {
|
||||
if (c.t === 'n') return '<i class="dbx-null">NULL</i>';
|
||||
if (c.t === 'b') return '<i class="dbx-null">BLOB ' + esc(c.v) + ' Б</i>';
|
||||
var v = c.v || '';
|
||||
return esc(v.length > 300 ? v.slice(0, 300) + '…' : v);
|
||||
}
|
||||
// The type a typed value goes as: what the value was, or what the column
|
||||
// is declared as, when the text fits it.
|
||||
function typeFor(text, was, decl) {
|
||||
var d = (decl || '').toUpperCase();
|
||||
var int = /^\s*-?\d+\s*$/.test(text), num = text.trim() !== '' && isFinite(Number(text));
|
||||
if (was === 'i' || (was !== 's' && /INT/.test(d))) return int ? 'i' : num ? 'r' : 's';
|
||||
if (was === 'r' || (was !== 's' && /REAL|FLOA|DOUB|NUM|DEC/.test(d))) return num ? 'r' : 's';
|
||||
return 's';
|
||||
}
|
||||
|
||||
function open(box, path, call, toast) {
|
||||
var S = {tables: [], cur: null, offset: 0, data: null, sel: new Set()};
|
||||
box.innerHTML =
|
||||
'<div class="dbx">' +
|
||||
'<div class="dbx-tabs" role="tablist"></div>' +
|
||||
'<div class="dbx-tools">' +
|
||||
' <span class="muted dbx-info"></span>' +
|
||||
' <span class="dbx-pager"><button type="button" class="btn ghost small icon dbx-prev" title="Назад" aria-label="Назад">‹</button>' +
|
||||
' <span class="muted dbx-range"></span><button type="button" class="btn ghost small icon dbx-next" title="Дальше" aria-label="Дальше">›</button></span>' +
|
||||
' <span class="dbx-sp"></span>' +
|
||||
' <button type="button" class="btn ghost small dbx-add">+ Строка</button>' +
|
||||
' <button type="button" class="btn danger small dbx-del" disabled>Удалить</button>' +
|
||||
'</div>' +
|
||||
'<div class="dbx-grid"></div>' +
|
||||
'<div class="dbx-sql" hidden>' +
|
||||
' <textarea class="dbx-q" spellcheck="false" placeholder="SELECT * FROM … - Ctrl+Enter выполнить"></textarea>' +
|
||||
' <div class="dbx-sqlbar"><button type="button" class="dbx-run">Выполнить</button><span class="muted dbx-sqlmsg"></span></div>' +
|
||||
' <div class="dbx-sqlres"></div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
var $ = function (s) { return box.querySelector(s); };
|
||||
|
||||
function busy(on) { box.querySelector('.dbx').classList.toggle('busy', on); }
|
||||
function req(op, args) {
|
||||
args.path = path;
|
||||
busy(true);
|
||||
return call(op, args).then(function (r) { busy(false); return r; }, function (e) { busy(false); throw e; });
|
||||
}
|
||||
|
||||
function tabs() {
|
||||
var t = $('.dbx-tabs');
|
||||
t.innerHTML = '';
|
||||
S.tables.forEach(function (x) {
|
||||
var b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.textContent = x.name;
|
||||
if (x.type === 'view') b.title = 'представление - только чтение';
|
||||
b.className = x.name === S.cur ? 'on' : '';
|
||||
b.onclick = function () { show(x.name, 0); };
|
||||
t.appendChild(b);
|
||||
});
|
||||
var q = document.createElement('button');
|
||||
q.type = 'button';
|
||||
q.textContent = 'SQL';
|
||||
q.className = 'dbx-sqltab' + (S.cur === null ? ' on' : '');
|
||||
q.onclick = sqlTab;
|
||||
t.appendChild(q);
|
||||
}
|
||||
|
||||
function show(name, offset) {
|
||||
S.cur = name; S.offset = offset; S.sel.clear();
|
||||
tabs();
|
||||
$('.dbx-sql').hidden = true;
|
||||
$('.dbx-grid').hidden = false;
|
||||
$('.dbx-tools').hidden = false;
|
||||
return req('db.rows', {table: name, offset: offset, limit: PAGE}).then(function (r) {
|
||||
if (!r.ok) { $('.dbx-grid').innerHTML = '<p class="dv-note">' + esc(r.error || 'ошибка') + '</p>'; return; }
|
||||
S.data = r.data;
|
||||
draw();
|
||||
});
|
||||
}
|
||||
|
||||
function draw() {
|
||||
var d = S.data, t = d.table, editable = !!t.key;
|
||||
// An INTEGER PRIMARY KEY column already shows the rowid.
|
||||
var showRowid = t.key === 'rowid' && !t.alias;
|
||||
$('.dbx-info').textContent = d.total + ' ' + plural(d.total) + (t.type === 'view' ? ' · представление' : !editable ? ' · только чтение' : '');
|
||||
$('.dbx-range').textContent = d.rows.length ? (d.offset + 1) + '–' + (d.offset + d.rows.length) : '';
|
||||
$('.dbx-prev').disabled = d.offset <= 0;
|
||||
$('.dbx-next').disabled = d.offset + d.rows.length >= d.total;
|
||||
$('.dbx-pager').hidden = d.total <= PAGE;
|
||||
$('.dbx-add').hidden = t.type !== 'table';
|
||||
$('.dbx-del').hidden = !editable;
|
||||
selInfo();
|
||||
var h = '<table class="dbx-t"><thead><tr>' + (editable ? '<th class="dbx-ck"><input type="checkbox" class="dbx-all" aria-label="Выбрать все"></th>' : '') +
|
||||
(showRowid ? '<th class="dbx-k">rowid</th>' : '');
|
||||
t.columns.forEach(function (c) {
|
||||
h += '<th title="' + esc(c.type + (c.pk ? ' · ключ' : '') + (c.notnull ? ' · NOT NULL' : '')) + '">' + esc(c.name) +
|
||||
(c.type ? '<small>' + esc(c.type.toLowerCase()) + '</small>' : '') + '</th>';
|
||||
});
|
||||
h += '</tr></thead><tbody>';
|
||||
d.rows.forEach(function (row, i) {
|
||||
h += '<tr data-i="' + i + '">' + (editable ? '<td class="dbx-ck"><input type="checkbox" aria-label="Выбрать строку"></td>' : '') +
|
||||
(showRowid ? '<td class="dbx-k">' + esc(row.k) + '</td>' : '');
|
||||
row.v.forEach(function (c, j) {
|
||||
h += '<td data-j="' + j + '" class="' + (c.t === 'i' || c.t === 'r' ? 'num' : '') + (editable && c.t !== 'b' ? ' ed' : '') + '">' + cellHTML(c) + '</td>';
|
||||
});
|
||||
h += '</tr>';
|
||||
});
|
||||
h += '</tbody></table>';
|
||||
if (!d.rows.length) h += '<p class="dv-note">строк нет</p>';
|
||||
$('.dbx-grid').innerHTML = h;
|
||||
$('.dbx-grid').scrollTop = 0;
|
||||
fixWidths($('.dbx-grid table'));
|
||||
}
|
||||
// The columns keep the widths they first came out at, so a value being
|
||||
// edited does not move the table about.
|
||||
function fixWidths(table) {
|
||||
if (!table) return;
|
||||
var ths = table.querySelectorAll('thead th'), ws = [], sum = 0;
|
||||
ths.forEach(function (th) { var w = Math.ceil(th.getBoundingClientRect().width); ws.push(w); sum += w; });
|
||||
ths.forEach(function (th, i) { th.style.width = ws[i] + 'px'; });
|
||||
table.style.width = sum + 'px';
|
||||
table.style.tableLayout = 'fixed';
|
||||
}
|
||||
function plural(n) {
|
||||
var m10 = n % 10, m100 = n % 100;
|
||||
return m10 === 1 && m100 !== 11 ? 'строка' : m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14) ? 'строки' : 'строк';
|
||||
}
|
||||
function selInfo() {
|
||||
var b = $('.dbx-del');
|
||||
b.disabled = !S.sel.size;
|
||||
b.textContent = S.sel.size ? 'Удалить (' + S.sel.size + ')' : 'Удалить';
|
||||
}
|
||||
|
||||
$('.dbx-prev').onclick = function () { show(S.cur, Math.max(0, S.offset - PAGE)); };
|
||||
$('.dbx-next').onclick = function () { show(S.cur, S.offset + PAGE); };
|
||||
|
||||
// ---- selecting rows ----
|
||||
$('.dbx-grid').addEventListener('change', function (ev) {
|
||||
var el = ev.target;
|
||||
if (el.classList.contains('dbx-all')) {
|
||||
box.querySelectorAll('tbody .dbx-ck input').forEach(function (c, i) { c.checked = el.checked; el.checked ? S.sel.add(i) : S.sel.delete(i); });
|
||||
} else if (el.closest('tbody .dbx-ck')) {
|
||||
var i = +el.closest('tr').dataset.i;
|
||||
el.checked ? S.sel.add(i) : S.sel.delete(i);
|
||||
}
|
||||
selInfo();
|
||||
});
|
||||
|
||||
// ---- editing a value ----
|
||||
var editing = null;
|
||||
$('.dbx-grid').addEventListener('click', function (ev) {
|
||||
var td = ev.target.closest('td.ed');
|
||||
if (!td || td.querySelector('textarea') || ev.target.closest('.dbx-edit')) return;
|
||||
edit(td);
|
||||
});
|
||||
function edit(td) {
|
||||
if (editing) finish(editing, true);
|
||||
var i = +td.parentNode.dataset.i, j = +td.dataset.j;
|
||||
var row = S.data.rows[i], cell = row.v[j], col = S.data.table.columns[j];
|
||||
var hgt = td.offsetHeight;
|
||||
td.classList.add('editing');
|
||||
td.innerHTML = '<div class="dbx-edit"><textarea spellcheck="false"></textarea><button type="button" class="dbx-nullb" title="Сделать NULL" aria-label="Сделать NULL">∅</button></div>';
|
||||
var ta = td.querySelector('textarea');
|
||||
ta.value = cell.t === 'n' ? '' : cell.v;
|
||||
ta.placeholder = cell.t === 'n' ? 'NULL' : '';
|
||||
ta.style.height = Math.max(hgt, 30) + 'px';
|
||||
ta.focus();
|
||||
ta.select();
|
||||
editing = {td: td, i: i, j: j, ta: ta, cell: cell, col: col, nul: false};
|
||||
ta.addEventListener('keydown', function (ev) {
|
||||
if (ev.key === 'Enter' && !ev.shiftKey) { ev.preventDefault(); finish(editing, true); }
|
||||
else if (ev.key === 'Escape') { ev.preventDefault(); ev.stopPropagation(); finish(editing, false); }
|
||||
else if (ev.key === 'Tab') {
|
||||
ev.preventDefault();
|
||||
var cur = editing, next = ev.shiftKey ? cur.td.previousElementSibling : cur.td.nextElementSibling;
|
||||
finish(cur, true);
|
||||
if (next && next.classList.contains('ed')) edit(next);
|
||||
}
|
||||
});
|
||||
ta.addEventListener('blur', function () { setTimeout(function () { if (editing && editing.ta === ta && document.activeElement !== td.querySelector('.dbx-nullb')) finish(editing, true); }, 120); });
|
||||
td.querySelector('.dbx-nullb').onmousedown = function (ev) { ev.preventDefault(); editing.nul = true; finish(editing, true); };
|
||||
}
|
||||
function finish(e, keep) {
|
||||
if (!e || editing !== e) return;
|
||||
editing = null;
|
||||
var text = e.ta.value, cell = e.cell;
|
||||
var value = e.nul ? {t: 'n'} : {t: typeFor(text, cell.t, e.col.type), v: text};
|
||||
var same = e.nul ? cell.t === 'n' : (cell.t !== 'n' || text !== '') && text === (cell.v || '') && (cell.t === value.t || cell.t === 's' || value.t === 's');
|
||||
if (cell.t === 'n' && !e.nul && text === '') same = true;
|
||||
e.td.classList.remove('editing');
|
||||
if (!keep || same) { e.td.innerHTML = cellHTML(cell); return; }
|
||||
e.td.innerHTML = cellHTML(value);
|
||||
e.td.classList.add('saving');
|
||||
req('db.set', {table: S.cur, key: S.data.rows[e.i].k, column: e.col.name, value: value}).then(function (r) {
|
||||
e.td.classList.remove('saving');
|
||||
if (!r.ok) {
|
||||
e.td.innerHTML = cellHTML(cell);
|
||||
flash(e.td, 'bad');
|
||||
toast(r.error || 'не сохранилось', true);
|
||||
return;
|
||||
}
|
||||
var got = r.data.value;
|
||||
S.data.rows[e.i].v[e.j] = got;
|
||||
// A changed key column moves the row's key too.
|
||||
if (r.data.key !== undefined) S.data.rows[e.i].k = r.data.key;
|
||||
e.td.innerHTML = cellHTML(got);
|
||||
e.td.className = (got.t === 'i' || got.t === 'r' ? 'num' : '') + ' ed';
|
||||
flash(e.td, 'ok');
|
||||
});
|
||||
}
|
||||
function flash(td, kind) {
|
||||
td.classList.remove('flash-ok', 'flash-bad');
|
||||
void td.offsetWidth;
|
||||
td.classList.add('flash-' + kind);
|
||||
}
|
||||
|
||||
// ---- adding and deleting rows ----
|
||||
$('.dbx-add').onclick = function () {
|
||||
if (box.querySelector('tr.dbx-new')) return;
|
||||
var t = S.data.table, tbody = box.querySelector('.dbx-t tbody');
|
||||
var tr = document.createElement('tr');
|
||||
tr.className = 'dbx-new';
|
||||
var h = (t.key ? '<td class="dbx-ck"></td>' : '') + (t.key === 'rowid' && !t.alias ? '<td class="dbx-k">новая</td>' : '');
|
||||
t.columns.forEach(function (c) {
|
||||
h += '<td><input type="text" data-c="' + esc(c.name) + '" placeholder="' + esc(c.default ? 'по умолч.: ' + c.default : c.notnull ? 'обязательно' : 'NULL') + '"></td>';
|
||||
});
|
||||
tr.innerHTML = h;
|
||||
tbody.insertBefore(tr, tbody.firstChild);
|
||||
var bar = document.createElement('tr');
|
||||
bar.className = 'dbx-newbar';
|
||||
bar.innerHTML = '<td colspan="' + (t.columns.length + 2) + '"><button type="button" class="small dbx-addok">Добавить</button> <button type="button" class="btn ghost small dbx-addno">Отмена</button> <span class="muted">пустое поле - значение по умолчанию</span></td>';
|
||||
tbody.insertBefore(bar, tr.nextSibling);
|
||||
$('.dbx-grid').scrollTop = 0;
|
||||
tr.querySelector('input').focus();
|
||||
var done = function () { tr.remove(); bar.remove(); };
|
||||
bar.querySelector('.dbx-addno').onclick = done;
|
||||
var add = function () {
|
||||
var values = {};
|
||||
tr.querySelectorAll('input').forEach(function (inp) {
|
||||
if (inp.value === '') return;
|
||||
var c = t.columns.filter(function (x) { return x.name === inp.dataset.c; })[0];
|
||||
values[c.name] = {t: typeFor(inp.value, '', c.type), v: inp.value};
|
||||
});
|
||||
req('db.add', {table: S.cur, values: values}).then(function (r) {
|
||||
if (!r.ok) { toast(r.error || 'не добавилось', true); return; }
|
||||
toast('строка добавлена');
|
||||
var total = S.data.total + 1;
|
||||
show(S.cur, Math.floor((total - 1) / PAGE) * PAGE);
|
||||
});
|
||||
};
|
||||
bar.querySelector('.dbx-addok').onclick = add;
|
||||
tr.addEventListener('keydown', function (ev) {
|
||||
if (ev.key === 'Enter') { ev.preventDefault(); add(); }
|
||||
if (ev.key === 'Escape') { ev.preventDefault(); ev.stopPropagation(); done(); }
|
||||
});
|
||||
};
|
||||
$('.dbx-del').onclick = 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); }};
|
||||
})();
|
||||
@@ -0,0 +1,675 @@
|
||||
// Light previews of office files for the file manager, with no libraries:
|
||||
// docx, xlsx and ods are zip archives of XML, opened with the browser's own
|
||||
// DecompressionStream; csv and tsv are parsed here; an old binary .doc only
|
||||
// gives up its text. Each turns into plain HTML the page styles itself.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const esc = s => String(s).replace(/[&<>"]/g, c => ({'&': '&', '<': '<', '>': '>', '"': '"'}[c]));
|
||||
|
||||
// ---------- zip ----------
|
||||
// Reads the central directory; a file is inflated only when asked for.
|
||||
function openZip(buf) {
|
||||
const b = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
|
||||
const dv = new DataView(b.buffer, b.byteOffset, b.byteLength);
|
||||
let eocd = -1;
|
||||
for (let i = b.length - 22; i >= Math.max(0, b.length - 65557); i--) {
|
||||
if (dv.getUint32(i, true) === 0x06054b50) { eocd = i; break; }
|
||||
}
|
||||
if (eocd < 0) throw new Error('не zip-архив');
|
||||
const count = dv.getUint16(eocd + 10, true);
|
||||
let p = dv.getUint32(eocd + 16, true);
|
||||
const files = new Map();
|
||||
const dec = new TextDecoder();
|
||||
for (let n = 0; n < count && dv.getUint32(p, true) === 0x02014b50; n++) {
|
||||
const method = dv.getUint16(p + 10, true), size = dv.getUint32(p + 20, true);
|
||||
const nameLen = dv.getUint16(p + 28, true), extra = dv.getUint16(p + 30, true), comment = dv.getUint16(p + 32, true);
|
||||
const local = dv.getUint32(p + 42, true);
|
||||
files.set(dec.decode(b.subarray(p + 46, p + 46 + nameLen)), {method, size, local});
|
||||
p += 46 + nameLen + extra + comment;
|
||||
}
|
||||
async function bytes(name) {
|
||||
const f = files.get(name);
|
||||
if (!f) return null;
|
||||
const start = f.local + 30 + dv.getUint16(f.local + 26, true) + dv.getUint16(f.local + 28, true);
|
||||
const raw = b.subarray(start, start + f.size);
|
||||
if (f.method === 0) return raw;
|
||||
if (f.method !== 8) throw new Error('сжатие zip, которое браузер не знает');
|
||||
const out = new Blob([raw]).stream().pipeThrough(new DecompressionStream('deflate-raw'));
|
||||
return new Uint8Array(await new Response(out).arrayBuffer());
|
||||
}
|
||||
async function xml(name) {
|
||||
const raw = await bytes(name);
|
||||
return raw ? new DOMParser().parseFromString(new TextDecoder().decode(raw), 'application/xml') : null;
|
||||
}
|
||||
return {files, bytes, xml};
|
||||
}
|
||||
|
||||
// Children of an element by local name, whatever the namespace prefix.
|
||||
const kids = (el, name) => el ? Array.from(el.children).filter(c => c.localName === name) : [];
|
||||
const kid = (el, name) => kids(el, name)[0] || null;
|
||||
const attr = (el, name) => {
|
||||
if (!el) return null;
|
||||
for (const a of el.attributes) if (a.localName === name) return a.value;
|
||||
return null;
|
||||
};
|
||||
|
||||
// ---------- docx ----------
|
||||
async function docx(buf) {
|
||||
const z = openZip(buf);
|
||||
const doc = await z.xml('word/document.xml');
|
||||
if (!doc) throw new Error('в архиве нет word/document.xml');
|
||||
// Style ids to names, to know headings whatever the language of Word.
|
||||
const styles = {};
|
||||
const st = await z.xml('word/styles.xml');
|
||||
if (st) for (const s of st.getElementsByTagNameNS('*', 'style')) {
|
||||
styles[attr(s, 'styleId')] = (attr(kid(s, 'name'), 'val') || '').toLowerCase();
|
||||
}
|
||||
const body = doc.getElementsByTagNameNS('*', 'body')[0];
|
||||
return '<div class="dv-doc">' + blocks(body, styles) + '</div>';
|
||||
}
|
||||
function blocks(el, styles) {
|
||||
let html = '', list = false;
|
||||
for (const c of el ? el.children : []) {
|
||||
if (c.localName === 'p') {
|
||||
const pPr = kid(c, 'pPr');
|
||||
const isList = !!kid(pPr, 'numPr');
|
||||
if (isList && !list) { html += '<ul>'; list = true; }
|
||||
if (!isList && list) { html += '</ul>'; list = false; }
|
||||
html += para(c, pPr, styles, isList);
|
||||
} else {
|
||||
if (list) { html += '</ul>'; list = false; }
|
||||
if (c.localName === 'tbl') html += table(c, styles);
|
||||
else if (c.localName === 'sdt') html += blocks(kid(c, 'sdtContent'), styles);
|
||||
}
|
||||
}
|
||||
return html + (list ? '</ul>' : '');
|
||||
}
|
||||
function para(p, pPr, styles, isList) {
|
||||
const name = styles[attr(kid(pPr, 'pStyle'), 'val')] || '';
|
||||
const h = /^heading (\d)/.exec(name);
|
||||
const tag = isList ? 'li' : name === 'title' ? 'h1' : h ? 'h' + Math.min(6, +h[1] + 1) : 'p';
|
||||
const jc = attr(kid(pPr, 'jc'), 'val');
|
||||
const align = jc === 'center' ? 'center' : (jc === 'right' || jc === 'end') ? 'right' : (jc === 'both' ? 'justify' : '');
|
||||
const inner = runs(p);
|
||||
return '<' + tag + (align ? ' style="text-align:' + align + '"' : '') + '>' + (inner || ' ') + '</' + tag + '>';
|
||||
}
|
||||
function runs(el) {
|
||||
let html = '';
|
||||
for (const c of el.children) {
|
||||
if (c.localName === 'r') html += run(c);
|
||||
else if (c.localName === 'hyperlink' || c.localName === 'smartTag' || c.localName === 'ins' || c.localName === 'sdt' || c.localName === 'sdtContent' || c.localName === 'fldSimple') {
|
||||
const inner = runs(c);
|
||||
html += c.localName === 'hyperlink' ? '<u class="dv-link">' + inner + '</u>' : inner;
|
||||
}
|
||||
}
|
||||
return html;
|
||||
}
|
||||
function run(r) {
|
||||
const pr = kid(r, 'rPr');
|
||||
const on = n => { const e = kid(pr, n); return !!e && attr(e, 'val') !== '0' && attr(e, 'val') !== 'false'; };
|
||||
let t = '';
|
||||
for (const c of r.children) {
|
||||
if (c.localName === 't') t += esc(c.textContent);
|
||||
else if (c.localName === 'tab') t += ' ';
|
||||
else if (c.localName === 'br' || c.localName === 'cr') t += '<br>';
|
||||
}
|
||||
if (!t) return '';
|
||||
if (on('b')) t = '<b>' + t + '</b>';
|
||||
if (on('i')) t = '<i>' + t + '</i>';
|
||||
if (kid(pr, 'u') && attr(kid(pr, 'u'), 'val') !== 'none') t = '<u>' + t + '</u>';
|
||||
if (on('strike')) t = '<s>' + t + '</s>';
|
||||
// A size set on the run itself, as many documents do instead of styles.
|
||||
const sz = +attr(kid(pr, 'sz'), 'val');
|
||||
if (sz && sz !== 24) t = '<span style="font-size:' + Math.max(9, Math.min(36, sz / 2)) + 'pt">' + t + '</span>';
|
||||
const va = attr(kid(pr, 'vertAlign'), 'val');
|
||||
if (va === 'superscript') t = '<sup>' + t + '</sup>';
|
||||
else if (va === 'subscript') t = '<sub>' + t + '</sub>';
|
||||
return t;
|
||||
}
|
||||
function table(tbl, styles) {
|
||||
let html = '<div class="dv-scroll"><table class="dv-table">';
|
||||
for (const tr of kids(tbl, 'tr')) {
|
||||
html += '<tr>';
|
||||
for (const tc of kids(tr, 'tc')) {
|
||||
const span = attr(kid(kid(tc, 'tcPr'), 'gridSpan'), 'val');
|
||||
html += '<td' + (span > 1 ? ' colspan="' + span + '"' : '') + '>' + blocks(tc, styles) + '</td>';
|
||||
}
|
||||
html += '</tr>';
|
||||
}
|
||||
return html + '</table></div>';
|
||||
}
|
||||
|
||||
// ---------- tables: one grid per sheet ----------
|
||||
const MAX_ROWS = 2000, MAX_COLS = 60;
|
||||
function colName(i) { let s = ''; for (i++; i > 0; i = Math.floor((i - 1) / 26)) s = String.fromCharCode(65 + (i - 1) % 26) + s; return s; }
|
||||
function grid(rows, names) {
|
||||
let cols = names ? names.length : 0;
|
||||
for (const r of rows) cols = Math.max(cols, r.length);
|
||||
cols = Math.min(cols, MAX_COLS);
|
||||
const cut = rows.length > MAX_ROWS || rows.some(r => r.length > MAX_COLS);
|
||||
let html = '<div class="dv-scroll dv-grid"><table class="dv-sheet"><thead><tr><th></th>';
|
||||
for (let c = 0; c < cols; c++) html += '<th>' + esc(names && names[c] != null ? names[c] : colName(c)) + '</th>';
|
||||
html += '</tr></thead><tbody>';
|
||||
rows.slice(0, MAX_ROWS).forEach((r, i) => {
|
||||
html += '<tr><th>' + (i + 1) + '</th>';
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const v = r[c] == null ? '' : r[c];
|
||||
html += '<td' + (typeof v === 'number' ? ' class="n"' : '') + '>' + esc(v) + '</td>';
|
||||
}
|
||||
html += '</tr>';
|
||||
});
|
||||
html += '</tbody></table></div>';
|
||||
return {html, cut};
|
||||
}
|
||||
// Several sheets: tabs over the grid.
|
||||
function sheets(list) {
|
||||
if (!list.length) return '<p class="dv-note">в книге нет листов</p>';
|
||||
let html = '';
|
||||
if (list.length > 1) {
|
||||
html += '<div class="dv-tabs">' + list.map((s, i) => '<button type="button" data-sheet="' + i + '"' + (i ? '' : ' class="on"') + '>' + esc(s.name) + '</button>').join('') + '</div>';
|
||||
}
|
||||
list.forEach((s, i) => {
|
||||
const g = grid(s.rows, s.cols);
|
||||
html += '<div class="dv-pane" data-pane="' + i + '"' + (i ? ' hidden' : '') + '>' + (s.note ? '<p class="dv-note">' + esc(s.note) + '</p>' : '') + g.html +
|
||||
(g.cut ? '<p class="dv-note">показаны первые ' + MAX_ROWS + ' строк и ' + MAX_COLS + ' столбцов</p>' : '') + '</div>';
|
||||
});
|
||||
return html;
|
||||
}
|
||||
function bindTabs(root) {
|
||||
root.querySelectorAll('[data-sheet]').forEach(b => b.onclick = () => {
|
||||
root.querySelectorAll('[data-sheet]').forEach(x => x.classList.toggle('on', x === b));
|
||||
root.querySelectorAll('[data-pane]').forEach(p => p.hidden = p.dataset.pane !== b.dataset.sheet);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- xlsx ----------
|
||||
async function xlsx(buf) {
|
||||
const z = openZip(buf);
|
||||
const wb = await z.xml('xl/workbook.xml');
|
||||
if (!wb) throw new Error('в архиве нет xl/workbook.xml');
|
||||
const rels = {};
|
||||
const rx = await z.xml('xl/_rels/workbook.xml.rels');
|
||||
if (rx) for (const r of rx.getElementsByTagNameNS('*', 'Relationship')) rels[r.getAttribute('Id')] = r.getAttribute('Target');
|
||||
const shared = [];
|
||||
const ss = await z.xml('xl/sharedStrings.xml');
|
||||
if (ss) for (const si of ss.getElementsByTagNameNS('*', 'si')) {
|
||||
shared.push(Array.from(si.getElementsByTagNameNS('*', 't')).filter(t => t.parentElement.localName !== 'rPh').map(t => t.textContent).join(''));
|
||||
}
|
||||
const list = [];
|
||||
for (const s of wb.getElementsByTagNameNS('*', 'sheet')) {
|
||||
let target = rels[attr(s, 'id')] || '';
|
||||
target = target.startsWith('/') ? target.slice(1) : 'xl/' + target.replace(/^\.\//, '');
|
||||
const doc = await z.xml(target);
|
||||
list.push({name: s.getAttribute('name') || '?', rows: doc ? xlsxRows(doc, shared) : []});
|
||||
}
|
||||
return sheets(list);
|
||||
}
|
||||
function xlsxRows(doc, shared) {
|
||||
const rows = [];
|
||||
for (const row of doc.getElementsByTagNameNS('*', 'row')) {
|
||||
const ri = (+row.getAttribute('r') || rows.length + 1) - 1;
|
||||
if (ri >= MAX_ROWS) break;
|
||||
const out = rows[ri] = rows[ri] || [];
|
||||
let next = 0;
|
||||
for (const c of kids(row, 'c')) {
|
||||
const ref = /^([A-Z]+)/.exec(c.getAttribute('r') || '');
|
||||
let ci = next;
|
||||
if (ref) { ci = 0; for (const ch of ref[1]) ci = ci * 26 + ch.charCodeAt(0) - 64; ci--; }
|
||||
next = ci + 1;
|
||||
if (ci >= MAX_COLS + 1) continue;
|
||||
const t = c.getAttribute('t'), v = kid(c, 'v');
|
||||
let val = v ? v.textContent : '';
|
||||
if (t === 's') val = shared[+val] || '';
|
||||
else if (t === 'inlineStr') val = Array.from(c.getElementsByTagNameNS('*', 't')).map(x => x.textContent).join('');
|
||||
else if (t === 'b') val = val === '1' ? 'TRUE' : 'FALSE';
|
||||
else if (t !== 'str' && t !== 'e' && val !== '' && !isNaN(val)) val = +val;
|
||||
out[ci] = val;
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < rows.length; i++) rows[i] = rows[i] || [];
|
||||
return rows;
|
||||
}
|
||||
|
||||
// ---------- ods / odt ----------
|
||||
async function ods(buf) {
|
||||
const z = openZip(buf);
|
||||
const doc = await z.xml('content.xml');
|
||||
if (!doc) throw new Error('в архиве нет content.xml');
|
||||
const list = [];
|
||||
for (const t of doc.getElementsByTagNameNS('*', 'table')) {
|
||||
if (t.parentElement && t.parentElement.localName === 'table-cell') continue;
|
||||
const rows = [];
|
||||
for (const r of t.getElementsByTagNameNS('*', 'table-row')) {
|
||||
const cells = [];
|
||||
for (const c of r.children) {
|
||||
if (c.localName !== 'table-cell' && c.localName !== 'covered-table-cell') continue;
|
||||
const rep = Math.min(+attr(c, 'number-columns-repeated') || 1, MAX_COLS + 1 - cells.length);
|
||||
const num = attr(c, 'value');
|
||||
const text = Array.from(c.getElementsByTagNameNS('*', 'p')).map(p => p.textContent).join('\n');
|
||||
const val = attr(c, 'value-type') === 'float' && num != null ? +num : text;
|
||||
for (let i = 0; i < rep; i++) cells.push(val);
|
||||
if (cells.length > MAX_COLS) break;
|
||||
}
|
||||
while (cells.length && cells[cells.length - 1] === '') cells.pop();
|
||||
const rep = Math.min(+attr(r, 'number-rows-repeated') || 1, MAX_ROWS + 1 - rows.length);
|
||||
for (let i = 0; i < rep; i++) rows.push(cells);
|
||||
if (rows.length > MAX_ROWS) break;
|
||||
}
|
||||
while (rows.length && !rows[rows.length - 1].length) rows.pop();
|
||||
list.push({name: attr(t, 'name') || '?', rows});
|
||||
}
|
||||
return sheets(list);
|
||||
}
|
||||
async function odt(buf) {
|
||||
const z = openZip(buf);
|
||||
const doc = await z.xml('content.xml');
|
||||
const body = doc && doc.getElementsByTagNameNS('*', 'text')[0];
|
||||
if (!body) throw new Error('в архиве нет текста');
|
||||
// Runs of spaces, tabs and breaks are elements of their own in ODF.
|
||||
const inline = el => {
|
||||
let html = '';
|
||||
for (const n of el.childNodes) {
|
||||
if (n.nodeType === 3) html += esc(n.nodeValue);
|
||||
else if (n.localName === 's') html += ' '.repeat(+attr(n, 'c') || 1);
|
||||
else if (n.localName === 'tab') html += ' ';
|
||||
else if (n.localName === 'line-break') html += '<br>';
|
||||
else if (n.nodeType === 1) html += inline(n);
|
||||
}
|
||||
return html;
|
||||
};
|
||||
const walk = el => {
|
||||
let html = '';
|
||||
for (const c of el.children) {
|
||||
if (c.localName === 'h') { const l = Math.min(6, (+attr(c, 'outline-level') || 1) + 1); html += '<h' + l + '>' + inline(c) + '</h' + l + '>'; }
|
||||
else if (c.localName === 'p') html += '<p>' + (inline(c) || ' ') + '</p>';
|
||||
else if (c.localName === 'list') html += '<ul>' + kids(c, 'list-item').map(li => '<li>' + walk(li) + '</li>').join('') + '</ul>';
|
||||
else if (c.localName === 'table') html += '<div class="dv-scroll"><table class="dv-table">' + Array.from(c.getElementsByTagNameNS('*', 'table-row')).map(r =>
|
||||
'<tr>' + kids(r, 'table-cell').map(td => '<td>' + walk(td) + '</td>').join('') + '</tr>').join('') + '</table></div>';
|
||||
else if (c.localName === 'section') html += walk(c);
|
||||
}
|
||||
return html;
|
||||
};
|
||||
return '<div class="dv-doc">' + walk(body) + '</div>';
|
||||
}
|
||||
|
||||
// ---------- csv / tsv ----------
|
||||
function csv(text, tab) {
|
||||
const first = text.slice(0, text.indexOf('\n') >>> 0 || text.length);
|
||||
const sep = tab ? '\t' : (first.split(';').length > first.split(',').length ? ';' : ',');
|
||||
const rows = [];
|
||||
let row = [], f = '', q = false;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const c = text[i];
|
||||
if (q) {
|
||||
if (c === '"') { if (text[i + 1] === '"') { f += '"'; i++; } else q = false; }
|
||||
else f += c;
|
||||
} else if (c === '"' && f === '') q = true;
|
||||
else if (c === sep) { row.push(f); f = ''; }
|
||||
else if (c === '\n' || c === '\r') {
|
||||
if (c === '\r' && text[i + 1] === '\n') i++;
|
||||
row.push(f); f = ''; rows.push(row); row = [];
|
||||
if (rows.length > MAX_ROWS) break;
|
||||
} else f += c;
|
||||
}
|
||||
if (f !== '' || row.length) { row.push(f); rows.push(row); }
|
||||
return sheets([{name: '', rows: rows.map(r => r.map(v => v !== '' && !isNaN(v) && /^-?[\d.]+$/.test(v.trim()) ? +v : v))}]);
|
||||
}
|
||||
|
||||
// ---------- old .doc ----------
|
||||
// Word 97-2003 is a binary format; without a full parser all that can be
|
||||
// shown is the text, picked out of the document's WordDocument stream as
|
||||
// the runs that read as words.
|
||||
function cfbStream(b, want) {
|
||||
const dv = new DataView(b.buffer, b.byteOffset, b.byteLength);
|
||||
if (dv.getUint32(0, true) !== 0xE011CFD0) return null;
|
||||
const ss = 1 << dv.getUint16(0x1E, true);
|
||||
const sector = n => 512 + n * ss;
|
||||
const fat = [];
|
||||
const addFat = n => { const o = sector(n); for (let i = 0; i < ss / 4; i++) fat.push(dv.getUint32(o + i * 4, true)); };
|
||||
for (let i = 0; i < 109; i++) { const n = dv.getUint32(0x4C + i * 4, true); if (n < 0xFFFFFFFA) addFat(n); }
|
||||
for (let n = dv.getUint32(0x44, true), k = 0; n < 0xFFFFFFFA && k < 1000; k++) {
|
||||
const o = sector(n);
|
||||
for (let i = 0; i < ss / 4 - 1; i++) { const m = dv.getUint32(o + i * 4, true); if (m < 0xFFFFFFFA) addFat(m); }
|
||||
n = dv.getUint32(o + ss - 4, true);
|
||||
}
|
||||
const chain = (start, size) => {
|
||||
const out = new Uint8Array(size);
|
||||
let n = start, at = 0;
|
||||
for (let guard = 0; n < 0xFFFFFFFA && at < size && guard < 1e6; guard++) {
|
||||
const o = sector(n), len = Math.min(ss, size - at);
|
||||
if (o + len > b.length) break;
|
||||
out.set(b.subarray(o, o + len), at);
|
||||
at += len;
|
||||
n = fat[n];
|
||||
}
|
||||
return out.subarray(0, at);
|
||||
};
|
||||
const dirStart = dv.getUint32(0x30, true);
|
||||
const dir = chain(dirStart, ss * 64);
|
||||
const dd = new DataView(dir.buffer, dir.byteOffset, dir.byteLength);
|
||||
for (let o = 0; o + 128 <= dir.length; o += 128) {
|
||||
const nameLen = dd.getUint16(o + 0x40, true);
|
||||
let name = '';
|
||||
for (let i = 0; i + 2 < nameLen; i += 2) name += String.fromCharCode(dd.getUint16(o + i, true));
|
||||
if (name === want && dd.getUint8(o + 0x42) === 2) return chain(dd.getUint32(o + 0x74, true), dd.getUint32(o + 0x78, true));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function doc(buf) {
|
||||
const all = new Uint8Array(buf);
|
||||
const b = cfbStream(all, 'WordDocument') || all;
|
||||
const ok = c => c === 9 || c === 10 || c === 13 || (c >= 32 && c < 127) || (c >= 0xA0 && c <= 0x24F) || (c >= 0x400 && c <= 0x4FF) || (c >= 0x2010 && c <= 0x2026) || c === 0x2116;
|
||||
const pieces = [];
|
||||
let cur = '';
|
||||
const flush = min => {
|
||||
const letters = (cur.match(/[A-Za-zА-Яа-яЁё0-9]/g) || []).length;
|
||||
if (cur.trim().length >= min && letters >= cur.trim().length * 0.5) pieces.push(cur);
|
||||
cur = '';
|
||||
};
|
||||
for (let i = 0; i + 1 < b.length; i += 2) {
|
||||
const c = b[i] | (b[i + 1] << 8);
|
||||
if (ok(c)) cur += String.fromCharCode(c); else flush(3);
|
||||
}
|
||||
flush(4);
|
||||
let text = pieces.join('\n');
|
||||
if (text.length < 40) { // a document that keeps its text 8-bit
|
||||
const p8 = [];
|
||||
for (let i = 0; i < b.length; i++) {
|
||||
const c = b[i];
|
||||
if (c === 9 || c === 10 || c === 13 || (c >= 32 && c < 127)) cur += String.fromCharCode(c);
|
||||
else { if (cur.trim().length >= 8 && (cur.match(/[A-Za-z]/g) || []).length >= cur.length * 0.5) p8.push(cur); cur = ''; }
|
||||
}
|
||||
text = p8.join('\n');
|
||||
}
|
||||
if (!text.trim()) throw new Error('текст не нашёлся');
|
||||
return '<p class="dv-note">старый формат .doc: показан только текст, без оформления</p><div class="dv-doc">' +
|
||||
text.split(/\r\n|\r|\n|\x07/).filter(l => l.trim()).map(l => '<p>' + esc(l) + '</p>').join('') + '</div>';
|
||||
}
|
||||
|
||||
// ---------- sqlite ----------
|
||||
// Reads the database file itself: the b-tree of sqlite_master for the
|
||||
// tables, then each table's b-tree for its first rows. No SQL engine.
|
||||
function sqlite(buf) {
|
||||
const b = new Uint8Array(buf);
|
||||
if (new TextDecoder().decode(b.subarray(0, 15)) !== 'SQLite format 3') throw new Error('не база SQLite');
|
||||
const dv = new DataView(b.buffer, b.byteOffset, b.byteLength);
|
||||
let ps = dv.getUint16(16);
|
||||
if (ps === 1) ps = 65536;
|
||||
const usable = ps - b[20];
|
||||
const enc = dv.getUint32(56);
|
||||
const dec = new TextDecoder(enc === 2 ? 'utf-16le' : enc === 3 ? 'utf-16be' : 'utf-8');
|
||||
const page = n => (n - 1) * ps;
|
||||
|
||||
function varint(o) {
|
||||
let v = 0;
|
||||
for (let i = 0; i < 9; i++) {
|
||||
const c = b[o + i];
|
||||
if (i === 8) return [v * 256 + c, o + 9];
|
||||
v = v * 128 + (c & 0x7f);
|
||||
if (!(c & 0x80)) return [v, o + i + 1];
|
||||
}
|
||||
}
|
||||
// A cell's payload, gathered from its overflow pages when it spills.
|
||||
function payload(o, size) {
|
||||
const max = usable - 35;
|
||||
if (size <= max) return b.subarray(o, o + size);
|
||||
const min = Math.floor((usable - 12) * 32 / 255) - 23;
|
||||
let local = min + (size - min) % (usable - 4);
|
||||
if (local > max) local = min;
|
||||
const out = new Uint8Array(size);
|
||||
out.set(b.subarray(o, o + local));
|
||||
let at = local, next = dv.getUint32(o + local);
|
||||
for (let guard = 0; next && at < size && guard < 1e6; guard++) {
|
||||
const po = page(next), len = Math.min(usable - 4, size - at);
|
||||
out.set(b.subarray(po + 4, po + 4 + len), at);
|
||||
at += len;
|
||||
next = dv.getUint32(po);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function record(p) {
|
||||
const pv = new DataView(p.buffer, p.byteOffset, p.byteLength);
|
||||
let [hlen, o] = vi(p, 0);
|
||||
const types = [];
|
||||
while (o < hlen) { const r = vi(p, o); types.push(r[0]); o = r[1]; }
|
||||
let d = hlen;
|
||||
return types.map(t => {
|
||||
let v = null;
|
||||
if (t >= 1 && t <= 6) {
|
||||
const n = [0, 1, 2, 3, 4, 6, 8][t];
|
||||
let x = 0;
|
||||
for (let i = 0; i < n; i++) x = x * 256 + p[d + i];
|
||||
if (p[d] & 0x80) x -= Math.pow(2, 8 * n);
|
||||
v = n === 8 && Math.abs(x) > Number.MAX_SAFE_INTEGER ? pv.getBigInt64(d).toString() : x;
|
||||
d += n;
|
||||
} else if (t === 7) { v = pv.getFloat64(d); d += 8; }
|
||||
else if (t === 8) v = 0;
|
||||
else if (t === 9) v = 1;
|
||||
else if (t >= 12 && t % 2 === 0) { const n = (t - 12) / 2; v = '[blob ' + n + ' B]'; d += n; }
|
||||
else if (t >= 13) { const n = (t - 13) / 2; v = dec.decode(p.subarray(d, d + n)); d += n; }
|
||||
return v;
|
||||
});
|
||||
}
|
||||
// varint over a payload buffer rather than the file
|
||||
function vi(p, o) {
|
||||
let v = 0;
|
||||
for (let i = 0; i < 9; i++) {
|
||||
const c = p[o + i];
|
||||
if (i === 8) return [v * 256 + c, o + 9];
|
||||
v = v * 128 + (c & 0x7f);
|
||||
if (!(c & 0x80)) return [v, o + i + 1];
|
||||
}
|
||||
}
|
||||
// Walks a table b-tree in rowid order, up to limit rows; also counts all.
|
||||
function table(root, limit) {
|
||||
const rows = [];
|
||||
let count = 0;
|
||||
const walk = (n, depth) => {
|
||||
if (depth > 40) return;
|
||||
const h = page(n) + (n === 1 ? 100 : 0);
|
||||
const type = b[h], cells = dv.getUint16(h + 3);
|
||||
const ptrs = h + (type === 0x05 ? 12 : 8);
|
||||
if (type === 0x0d) {
|
||||
count += cells;
|
||||
for (let i = 0; i < cells && rows.length < limit; i++) {
|
||||
let o = page(n) + dv.getUint16(ptrs + i * 2), size, rowid;
|
||||
[size, o] = varint(o);
|
||||
[rowid, o] = varint(o);
|
||||
rows.push({rowid, values: record(payload(o, size))});
|
||||
}
|
||||
} else if (type === 0x05) {
|
||||
for (let i = 0; i < cells; i++) walk(dv.getUint32(page(n) + dv.getUint16(ptrs + i * 2)), depth + 1);
|
||||
walk(dv.getUint32(h + 8), depth + 1);
|
||||
}
|
||||
};
|
||||
walk(root, 0);
|
||||
return {rows, count};
|
||||
}
|
||||
// Column names out of CREATE TABLE; an INTEGER PRIMARY KEY is the rowid.
|
||||
function columns(sql) {
|
||||
const m = /\(([\s\S]*)\)/.exec(sql || '');
|
||||
if (!m) return {names: [], rowidCol: -1};
|
||||
const parts = [];
|
||||
let depth = 0, cur = '';
|
||||
for (const c of m[1]) {
|
||||
if (c === '(') depth++;
|
||||
if (c === ')') depth--;
|
||||
if (c === ',' && !depth) { parts.push(cur); cur = ''; } else cur += c;
|
||||
}
|
||||
parts.push(cur);
|
||||
const names = [];
|
||||
let rowidCol = -1;
|
||||
for (const p of parts) {
|
||||
const t = p.trim();
|
||||
if (!t || /^(constraint|primary\s+key|unique|check|foreign\s+key)\b/i.test(t)) continue;
|
||||
const nm = /^("(?:[^"]|"")+"|`[^`]+`|\[[^\]]+\]|\S+)/.exec(t)[1].replace(/^["`\[]|["`\]]$/g, '');
|
||||
if (/^\S+\s+integer\s+primary\s+key\b/i.test(t.replace(/^("(?:[^"]|"")+"|`[^`]+`|\[[^\]]+\])/, 'x'))) rowidCol = names.length;
|
||||
names.push(nm);
|
||||
}
|
||||
return {names, rowidCol};
|
||||
}
|
||||
|
||||
const master = table(1, 100000).rows.map(r => ({type: r.values[0], name: r.values[1], root: r.values[3], sql: r.values[4] || ''}));
|
||||
const list = [];
|
||||
for (const t of master.filter(x => x.type === 'table' && x.root).sort((a, c) => String(a.name).localeCompare(String(c.name)))) {
|
||||
if (/without\s+rowid/i.test(t.sql)) { list.push({name: t.name, rows: [], note: 'таблица WITHOUT ROWID - её строки здесь не показываются'}); continue; }
|
||||
const {names, rowidCol} = columns(t.sql);
|
||||
let data;
|
||||
try { data = table(t.root, MAX_ROWS); } catch (e) { list.push({name: t.name, rows: [], note: 'не удалось прочитать: ' + e.message}); continue; }
|
||||
const rows = data.rows.map(r => { const v = r.values.slice(); if (rowidCol >= 0 && v[rowidCol] == null) v[rowidCol] = r.rowid; return v; });
|
||||
list.push({name: t.name, cols: names, rows, note: data.count + ' ' + (data.count % 10 === 1 && data.count % 100 !== 11 ? 'строка' : data.count % 10 >= 2 && data.count % 10 <= 4 && (data.count % 100 < 12 || data.count % 100 > 14) ? 'строки' : 'строк') + (data.count > rows.length ? ', показаны первые ' + rows.length : '')});
|
||||
}
|
||||
if (!list.length) return '<p class="dv-note">в базе нет таблиц</p>';
|
||||
return sheets(list);
|
||||
}
|
||||
|
||||
// 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};
|
||||
})();
|
||||
Loaded 100 of 123 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user