Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fa3de757c | ||
|
|
0279c1d887 | ||
|
|
0ccac0bc82 | ||
|
|
e5a45b872a | ||
|
|
9e684605ef | ||
|
|
13897c7478 | ||
|
|
4d2cfe8244 | ||
|
|
25ffa60d09 | ||
|
|
baee90b564 | ||
|
|
79828e3f8a | ||
|
|
021da93229 | ||
|
|
36dc12b69d | ||
|
|
1297ab36d0 | ||
|
|
fd382d6313 | ||
|
|
fc488717d3 | ||
|
|
dbb46173a3 | ||
|
|
b47e824692 | ||
|
|
5eca9ca215 | ||
|
|
467bd3cb4f | ||
|
|
18d6684c55 | ||
|
|
1a38c2ac18 | ||
|
|
5a4dc596d5 | ||
|
|
5a204ae9bb | ||
|
|
119b524825 | ||
|
|
29353e5e6a | ||
|
|
47f1b2e55b | ||
|
|
73bb720ec6 | ||
|
|
6dd1120a41 | ||
|
|
67222e2e05 | ||
|
|
e7b5d28470 | ||
|
|
d2d6fe0100 | ||
|
|
e983fc179a | ||
|
|
89d5bfd3ab | ||
|
|
55c37a0097 | ||
|
|
cefdae267b | ||
|
|
44e7137d86 | ||
|
|
10ffa393ca | ||
|
|
114c6fc530 | ||
|
|
0aa066fd01 | ||
|
|
21436d5c73 | ||
|
|
65359f5497 |
No files matched your search
@@ -1,74 +1,151 @@
|
|||||||
# LPRA - LongPolling Remote Access
|
# LPRA - LongPolling Remote Access
|
||||||
|
|
||||||
Лёгкий self-hosted сервис для доступа к Linux-машинам **без белого IP** (за NAT).
|
Лёгкий self-hosted сервис для доступа к Linux-машинам **без белого IP** (за NAT).
|
||||||
Агент на клиенте сам исходяще подключается к серверу по HTTP long-polling; админ
|
Агент на клиенте сам исходяще подключается к серверу - по WebSocket, а где он не проходит,
|
||||||
в веб-панели видит список машин и открывает по каждой **терминал в браузере** (по умолчанию от root, пользователя можно сменить).
|
по HTTP long-polling. В веб-панели видно список машин, и по каждой есть терминал в браузере,
|
||||||
|
файловый менеджер, проброс портов и статус.
|
||||||
|
|
||||||
```
|
```
|
||||||
браузер ──WS──> [nginx :8445 TLS] ──> [Go server 127.0.0.1:8091] <──long-poll── агент (root, за NAT)
|
браузер ──HTTPS/WS──> [Cloudflare] ──> [nginx TLS] ──> [Go server 127.0.0.1:8091] <──WS / long-poll── агент (за NAT)
|
||||||
│
|
│ ▲
|
||||||
SQLite
|
SQLite └──── прямой TCP/UDP-туннель :8446 (проброс tcp/udp)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Возможности
|
||||||
|
- **Терминал** - вкладки на нескольких машинах сразу, до четырёх терминалов в одной вкладке
|
||||||
|
(перетаскиванием, с изменяемыми размерами), переименование вкладок. На телефоне - панель
|
||||||
|
клавиш (Esc, Ctrl, Alt, стрелки и т.д.).
|
||||||
|
- **SSH-терминал** - в меню «+»: агент открывает SSH-соединение к другому хосту (по паролю,
|
||||||
|
ключу - можно загрузить файл - или своими ключами из `~/.ssh`), шелл открывается во вкладке,
|
||||||
|
а шаги подключения видны в статусе. Ключ хоста запоминается при первом входе
|
||||||
|
(`ssh_known_hosts` рядом с `state.json` агента); если он сменится, вход откажет.
|
||||||
|
- **Файлы** - просмотр, редактор с подсветкой, hex-редактор, предпросмотр картинок, видео, PDF,
|
||||||
|
баз SQLite с правкой (значение по клику, добавление и удаление строк, вкладка SQL; агент меняет базу через сам SQLite, так что работающая база не ломается); загрузка и скачивание до 512 МБ кусками по 4 МБ (проходит через Cloudflare);
|
||||||
|
свойства (chmod/chown), zip/unzip.
|
||||||
|
- **Перемещение и копирование** - внутри машины или, кнопкой «сеть» в том же окне, **на другую
|
||||||
|
машину или на SSH-хост**: папка уходит потоком tar без промежуточных файлов, с прогрессом,
|
||||||
|
скоростью и отменой. «Переместить» удаляет исходник после успешной отправки. Правила для
|
||||||
|
существующего: сообщить / заменить / оставить как есть.
|
||||||
|
- **Проброс портов** - `http` через панель, `tcp`/`udp` через прямой туннель.
|
||||||
|
- **Статус** - нагрузка, память, диски, сеть, тест скорости канала до сервера.
|
||||||
|
- **Вход** - пароль и/или ключи доступа (passkeys); пароль можно выключить, когда есть ключ.
|
||||||
|
- **PIN-код** (необязательный, 4 цифры) - спрашивается в каждой новой вкладке браузера, но не при
|
||||||
|
перезагрузке. На устройстве можно не спрашивать его 5/15/30/60 минут после ввода.
|
||||||
|
Пять неверных попыток - выход из сессии.
|
||||||
|
|
||||||
|
## Пользователи и роли
|
||||||
|
- **Администратор** видит всё: все машины, настройки, пользователей, ufw.
|
||||||
|
- **Пользователь** видит только выданные ему машины (терминал, файлы, порты, статус, «Изменить» без удаления);
|
||||||
|
настроек, других пользователей и чужих машин для него нет.
|
||||||
|
|
||||||
|
Роль и машины задаются в «Настройки → Пользователи» (квадратная кнопка у пользователя открывает список машин).
|
||||||
|
Там же галочка «Пароль» включает или выключает вход по паролю (выключить можно, когда у человека есть ключ доступа),
|
||||||
|
а «Закрепить» не даёт ему поменять это самому. Последнего администратора нельзя удалить или понизить.
|
||||||
|
|
||||||
## Компоненты
|
## Компоненты
|
||||||
- **Сервер** (`cmd/server`) - админ-панель + точка встречи для агентов. За nginx (TLS).
|
- **Сервер** (`cmd/server`) - панель и точка встречи для агентов. Ставится за nginx (TLS).
|
||||||
- **Агент** (`cmd/agent`) - Linux-бинарь, systemd-сервис от root, PTY по запросу.
|
- **Агент** (`cmd/agent`) - бинарь для Linux (amd64/arm64), systemd-сервис от root.
|
||||||
- **install.sh** - интерактивный установщик, отдаётся сервером как статика.
|
- **install.sh** - установщик агента, отдаётся сервером.
|
||||||
|
|
||||||
## Сборка
|
## Сборка
|
||||||
```sh
|
```sh
|
||||||
./build.sh
|
./build.sh
|
||||||
```
|
```
|
||||||
Получаются `bin/lpra-server` и `dist/agent-linux-{amd64,arm64}`.
|
Получаются `bin/lpra-server*` и `dist/agent-linux-{amd64,arm64}`.
|
||||||
|
Версия задаётся в одном месте - `VERSION.txt`, она вшивается в сервер и агент.
|
||||||
|
|
||||||
## Запуск сервера
|
## Запуск сервера
|
||||||
```sh
|
```sh
|
||||||
./bin/lpra-server -addr 127.0.0.1:8091 -dist ./dist -db ./lpra.db
|
./bin/lpra-server -addr 127.0.0.1:8091 -dist ./dist -db ./lpra.db
|
||||||
```
|
```
|
||||||
Поставьте перед ним nginx по `deploy/nginx.conf.example` (TLS, long-poll/WS).
|
| Флаг | Переменная | По умолчанию | |
|
||||||
Локально без TLS можно добавить `-insecure-cookies`.
|
|---|---|---|---|
|
||||||
|
| `-addr` | `LPRA_ADDR` | `127.0.0.1:8091` | адрес HTTP (за nginx) |
|
||||||
|
| `-db` | `LPRA_DB` | `lpra.db` | база SQLite |
|
||||||
|
| `-dist` | `LPRA_DIST` | `dist` | бинарники агента для `/download/` |
|
||||||
|
| `-tunnel` | `LPRA_TUNNEL_ADDR` | `:8446` | прямой туннель TCP+UDP (пусто - выключен) |
|
||||||
|
| `-insecure-cookies` | `LPRA_INSECURE_COOKIES=1` | | куки без Secure - только для локального запуска без TLS |
|
||||||
|
|
||||||
Первый запуск: откройте `/setup` - мастер создаст администратора. Дальше `/settings`:
|
Пример юнита - `deploy/lpra-server.service`.
|
||||||
адрес хоста (по умолчанию `https://localhost:8445`), enroll-секрет и готовая
|
|
||||||
команда установки.
|
|
||||||
|
|
||||||
## Установка агента на клиент
|
Первый запуск: откройте `/setup` - мастер создаст администратора. Дальше в `/settings`:
|
||||||
Одной командой (под root):
|
адрес хоста, enroll-секрет, короткий код установки и готовая команда.
|
||||||
```sh
|
|
||||||
curl -fsSL https://localhost:8445/install.sh | sudo HOST=https://localhost:8445 ENROLL_SECRET=<секрет> sh
|
### nginx
|
||||||
|
Конфиг - `deploy/nginx.conf.example`. Обязательно:
|
||||||
|
- заголовки `Upgrade` / `Connection` (через `map $http_upgrade $connection_upgrade`) - без них
|
||||||
|
WebSocket не пройдёт, и агенты и терминалы останутся на long-polling (работает, но медленнее
|
||||||
|
на больших выводах);
|
||||||
|
- `proxy_read_timeout` больше 25 с (удержание long-poll), `proxy_buffering off`.
|
||||||
|
|
||||||
|
### Прямой адрес (без Cloudflare)
|
||||||
|
В «Настройки → Адрес хоста» можно задать второй, прямой адрес - например `https://direct.lpra.example.com`,
|
||||||
|
DNS-запись без проксирования. Тогда:
|
||||||
|
- агенты, не достучавшись до основного адреса 30 секунд, переходят на прямой и каждые 5 минут проверяют,
|
||||||
|
не ожил ли основной; уже установленные агенты получают прямой адрес с сервера сами (он хранится в `state.json`);
|
||||||
|
- установщик берёт адреса с того домена, с которого его скачали: с основного - агент работает через основной
|
||||||
|
с резервом на прямой, с прямого - наоборот (и бинарник качается с прямого); если первый адрес не отвечает,
|
||||||
|
бинарник качается со второго. В «Полных вариантах» есть короткая команда и через прямой адрес;
|
||||||
|
- в «Изменить → Связь с сервером» у клиента, подключённого через прямой адрес, после статуса стоит «(direct)»;
|
||||||
|
- если хост туннеля не задан, агентам отдаётся хост прямого адреса;
|
||||||
|
- ключи доступа основного домена работают и на его поддомене.
|
||||||
|
|
||||||
|
### Cloudflare
|
||||||
|
Работает за проксирующим Cloudflare: WebSocket он пропускает, файлы идут кусками меньше лимита
|
||||||
|
на запрос, статика с хешем в имени кэшируется. Чтобы панель видела настоящие IP (ограничение
|
||||||
|
попыток входа и кода установки считается по адресу), включите в nginx real IP:
|
||||||
|
```nginx
|
||||||
|
real_ip_header CF-Connecting-IP;
|
||||||
|
set_real_ip_from 173.245.48.0/20; # и остальные диапазоны с https://www.cloudflare.com/ips/
|
||||||
```
|
```
|
||||||
Или интерактивно (спросит секрет, а если агент уже стоит - предложит удалить):
|
Прямой туннель (`:8446`) - это не HTTP, через проксирующий Cloudflare он не идёт: в
|
||||||
|
«Настройки → Прямой туннель» укажите для агентов IP хоста или DNS-имя без проксирования.
|
||||||
|
|
||||||
|
## Установка агента
|
||||||
|
Короткой командой из `/settings` (код из 3-24 букв и цифр, кнопка «Новый код» меняет его):
|
||||||
```sh
|
```sh
|
||||||
curl -fsSL https://localhost:8445/install.sh | sudo sh
|
curl -fsSL https://panel.example/i/<код> | sudo sh
|
||||||
```
|
```
|
||||||
|
Или полной:
|
||||||
|
```sh
|
||||||
|
curl -fsSL https://panel.example/install.sh | sudo HOST=https://panel.example ENROLL_SECRET=<секрет> sh
|
||||||
|
```
|
||||||
|
Без параметров установщик спросит секрет, а если агент уже стоит - предложит удалить.
|
||||||
|
Обновить агента можно кнопкой в панели («Изменить» → обновить).
|
||||||
|
|
||||||
|
## Связь агента с сервером
|
||||||
|
В «Изменить» → «Связь с сервером» для каждой машины:
|
||||||
|
- **auto** (по умолчанию) - WebSocket; если он не подключается, long-polling ровно минуту, потом
|
||||||
|
снова попытка WebSocket;
|
||||||
|
- **ws** - только WebSocket;
|
||||||
|
- **lp** - только long-polling.
|
||||||
|
|
||||||
## Проброс портов и прямой туннель
|
## Проброс портов и прямой туннель
|
||||||
Правила проброса создаются на странице машины. Режим `http` идёт через адрес панели (long-poll),
|
Правила создаются на странице машины «Порты». Режим `http` идёт через адрес панели,
|
||||||
режимы `tcp`/`udp` - через прямой туннель: агент держит исходящее соединение к порту туннеля хоста
|
`tcp`/`udp` - через прямой туннель: агент держит исходящее соединение к порту туннеля хоста,
|
||||||
(флаг `-tunnel` / `LPRA_TUNNEL_ADDR`).
|
по нему мультиплексируются все соединения.
|
||||||
|
|
||||||
Порт туннеля и хост, к которому для него подключаются агенты (например, IP вместо домена панели),
|
Порт туннеля и хост для агентов меняются в `/settings` → «Прямой туннель»; агенты забирают их
|
||||||
меняются в `/settings` → «Прямой туннель». Агенты забирают адрес с панели сами - при подключении и
|
сами, переустановка не нужна. Локальные `TUNNEL_ADDR` / `TUNNEL_PORT` в окружении агента имеют
|
||||||
при каждом опросе, переустановка не нужна. Локальные `TUNNEL_ADDR` / `TUNNEL_PORT` в окружении агента
|
приоритет (`TUNNEL_ADDR=off` отключает туннель).
|
||||||
имеют приоритет над настройкой панели (`TUNNEL_ADDR=off` отключает туннель).
|
|
||||||
|
|
||||||
Если на хосте активен ufw и сервер запущен от root, панель сама открывает порт хоста правила
|
Если на хосте активен ufw и сервер запущен от root, панель сама открывает порты правил и порт
|
||||||
(tcp для `http`/`tcp`, udp для `udp`) и порт туннеля для прямых режимов, а при удалении правила - закрывает.
|
туннеля, а при удалении правила закрывает. При `ProtectSystem=strict` в юните нужно
|
||||||
При `ProtectSystem=strict` в systemd-юните ufw нужны права на запись: `ReadWritePaths=/etc/ufw /run`.
|
`ReadWritePaths=/etc/ufw /run`.
|
||||||
|
|
||||||
## Версия
|
|
||||||
Версия задаётся в одном месте - `VERSION.txt`; файл вшивается в сервер и агент при сборке.
|
|
||||||
|
|
||||||
## Пользователь терминала
|
## Пользователь терминала
|
||||||
По умолчанию терминал открывается от пользователя, под которым запущен агент (на Linux - root).
|
По умолчанию терминал открывается от пользователя агента (root). В «Изменить машину» можно выбрать
|
||||||
В окне «Изменить машину» можно выбрать другого пользователя машины или создать нового
|
другого пользователя или создать нового (с паролем, по желанию с sudo) - если агент запущен от root.
|
||||||
(имя, пароль, опционально - добавить в sudo). Смена и создание работают, только если агент запущен от root.
|
Файловый менеджер работает от пользователя агента.
|
||||||
Файловый менеджер по-прежнему работает от пользователя агента.
|
|
||||||
|
|
||||||
## Безопасность
|
## Безопасность
|
||||||
- Пароли админов - bcrypt; сессии в HttpOnly/Secure/SameSite куках; CSRF на формах; rate-limit логина.
|
- Пароли админов - bcrypt; сессии в HttpOnly/Secure/SameSite куках; CSRF на формах;
|
||||||
- Агенты: общий enroll-секрет → индивидуальный токен (хранится хешем, сверка constant-time).
|
ограничение попыток входа, PIN и кода установки.
|
||||||
- Открытие терминала пишется в таблицу `audit` (терминал даёт root-доступ), как и смена/создание пользователя.
|
- Ключи доступа (WebAuthn) - вход без пароля; пароль можно выключить совсем.
|
||||||
|
- Агенты: общий enroll-секрет → индивидуальный токен (хранится хешем, сверка constant-time);
|
||||||
|
туннель авторизуется HMAC от токена.
|
||||||
|
- SSH-пароли и ключи передаются агенту один раз для подключения и нигде не сохраняются.
|
||||||
|
- Открытие терминала и SSH, перенос файлов на другие машины, смена пользователя пишутся в `audit`.
|
||||||
|
|
||||||
## Скриншоты
|
## Скриншоты
|
||||||
<img width="1256" height="709" alt="image" src="https://github.com/user-attachments/assets/5f0b30f1-d5e3-4b62-817e-4041641df6e3" />
|
<img width="1256" height="709" alt="image" src="https://github.com/user-attachments/assets/5f0b30f1-d5e3-4b62-817e-4041641df6e3" />
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
0.0.7
|
0.1.2
|
||||||
+7
-1
@@ -11,6 +11,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
"github.com/x-grk/longpolling-remote-access/internal/agent"
|
"github.com/x-grk/longpolling-remote-access/internal/agent"
|
||||||
@@ -21,6 +22,7 @@ func main() {
|
|||||||
host := envOr("HOST", "https://REDACTED:8445")
|
host := envOr("HOST", "https://REDACTED:8445")
|
||||||
cfg := agent.Config{
|
cfg := agent.Config{
|
||||||
Host: host,
|
Host: host,
|
||||||
|
Fallback: strings.TrimRight(os.Getenv("FALLBACK_HOST"), "/"),
|
||||||
EnrollSecret: os.Getenv("ENROLL_SECRET"),
|
EnrollSecret: os.Getenv("ENROLL_SECRET"),
|
||||||
Name: os.Getenv("AGENT_NAME"),
|
Name: os.Getenv("AGENT_NAME"),
|
||||||
StatePath: envOr("STATE_PATH", "/etc/lpagent/state.json"),
|
StatePath: envOr("STATE_PATH", "/etc/lpagent/state.json"),
|
||||||
@@ -38,7 +40,11 @@ func main() {
|
|||||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
if err := agent.New(cfg).Run(ctx); err != nil {
|
err := agent.New(cfg).Run(ctx)
|
||||||
|
// Stopping (an update, a restart): hang the terminals up, so their shells
|
||||||
|
// save their history instead of being killed.
|
||||||
|
agent.CloseTerminals()
|
||||||
|
if err != nil {
|
||||||
log.Fatalf("agent: %v", err)
|
log.Fatalf("agent: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,21 @@
|
|||||||
# Put this in /etc/nginx/sites-available/lp_agent and symlink it into
|
# Put this in /etc/nginx/sites-available/lp_agent and symlink it into
|
||||||
# sites-enabled, then: sudo nginx -t && sudo systemctl reload nginx
|
# sites-enabled, then: sudo nginx -t && sudo systemctl reload nginx
|
||||||
#
|
#
|
||||||
# Everything (agent + browser terminal) is plain HTTP long-polling, so NO
|
# Agents connect by WebSocket (from 0.1.0), with HTTP long polling to fall
|
||||||
# WebSocket config is needed. The two settings that matter:
|
# back on; the browser uses plain HTTP. The settings that matter:
|
||||||
# - proxy_buffering off -> deliver long-poll responses immediately
|
# - Upgrade / Connection headers -> let the agents' WebSocket through
|
||||||
# - proxy_read_timeout 120s -> longer than the 25s long-poll hold
|
# - 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;"
|
# NOTE: proxy_pass MUST start with "http://". A bare "proxy_pass 127.0.0.1:8091;"
|
||||||
# causes nginx error: [emerg] invalid URL prefix.
|
# causes nginx error: [emerg] invalid URL prefix.
|
||||||
|
|
||||||
|
# "Connection: upgrade" for a WebSocket, "close" for everything else.
|
||||||
|
map $http_upgrade $connection_upgrade {
|
||||||
|
default upgrade;
|
||||||
|
'' close;
|
||||||
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 8445 ssl;
|
listen 8445 ssl;
|
||||||
server_name REDACTED;
|
server_name REDACTED;
|
||||||
@@ -33,6 +40,9 @@ server {
|
|||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
# Agents' WebSocket.
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
|
||||||
# Long-poll behaviour: no buffering, generous read timeout.
|
# Long-poll behaviour: no buffering, generous read timeout.
|
||||||
proxy_buffering off;
|
proxy_buffering off;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ module github.com/x-grk/longpolling-remote-access
|
|||||||
go 1.26
|
go 1.26
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/coder/websocket v1.8.12
|
||||||
github.com/creack/pty v1.1.21
|
github.com/creack/pty v1.1.21
|
||||||
golang.org/x/crypto v0.31.0
|
golang.org/x/crypto v0.31.0
|
||||||
golang.org/x/sys v0.28.0
|
golang.org/x/sys v0.28.0
|
||||||
@@ -12,9 +13,19 @@ require (
|
|||||||
require (
|
require (
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.16 // indirect
|
github.com/mattn/go-isatty v0.0.16 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
golang.org/x/mod v0.3.0 // indirect
|
||||||
|
golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78 // indirect
|
||||||
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
|
||||||
|
lukechampine.com/uint128 v1.2.0 // indirect
|
||||||
|
modernc.org/cc/v3 v3.40.0 // indirect
|
||||||
|
modernc.org/ccgo/v3 v3.16.13 // indirect
|
||||||
modernc.org/libc v1.22.5 // indirect
|
modernc.org/libc v1.22.5 // indirect
|
||||||
modernc.org/mathutil v1.5.0 // indirect
|
modernc.org/mathutil v1.5.0 // indirect
|
||||||
modernc.org/memory v1.5.0 // indirect
|
modernc.org/memory v1.5.0 // indirect
|
||||||
|
modernc.org/opt v0.1.3 // indirect
|
||||||
|
modernc.org/strutil v1.1.3 // indirect
|
||||||
|
modernc.org/token v1.0.1 // indirect
|
||||||
)
|
)
|
||||||
@@ -1,28 +1,80 @@
|
|||||||
|
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
|
||||||
|
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
|
||||||
github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0=
|
github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0=
|
||||||
github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
|
github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||||
|
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||||
|
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
|
||||||
github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
|
github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
|
||||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
|
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||||
|
golang.org/x/mod v0.3.0 h1:RM4zey1++hCTbCVQfnWeKs9/IEsaBLA8vTkd0WVtmH4=
|
||||||
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78 h1:M8tBwCtWD/cZV9DZpFYRUgaymAYAr+aIUTWzDaM3uPs=
|
||||||
|
golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
||||||
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
|
||||||
|
lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
|
||||||
|
modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw=
|
||||||
|
modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0=
|
||||||
|
modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw=
|
||||||
|
modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY=
|
||||||
|
modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk=
|
||||||
|
modernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ=
|
||||||
|
modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM=
|
||||||
|
modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM=
|
||||||
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
|
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
|
||||||
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
||||||
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||||
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||||
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
||||||
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||||
|
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
||||||
|
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||||
modernc.org/sqlite v1.24.0 h1:EsClRIWHGhLTCX44p+Ri/JLD+vFGo0QGjasg2/F9TlI=
|
modernc.org/sqlite v1.24.0 h1:EsClRIWHGhLTCX44p+Ri/JLD+vFGo0QGjasg2/F9TlI=
|
||||||
modernc.org/sqlite v1.24.0/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
|
modernc.org/sqlite v1.24.0/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
|
||||||
|
modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY=
|
||||||
|
modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
|
||||||
|
modernc.org/tcl v1.15.2 h1:C4ybAYCGJw968e+Me18oW55kD/FexcHbqH2xak1ROSY=
|
||||||
|
modernc.org/tcl v1.15.2/go.mod h1:3+k/ZaEbKrC8ePv8zJWPtBSW0V7Gg9g8rkmhI1Kfs3c=
|
||||||
|
modernc.org/token v1.0.1 h1:A3qvTqOwexpfZZeyI0FeGPDlSWX5pjZu9hF4lU+EKWg=
|
||||||
|
modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
|
modernc.org/z v1.7.3 h1:zDJf6iHjrnB+WRD88stbXokugjyc0/pB91ri1gO6LZY=
|
||||||
|
modernc.org/z v1.7.3/go.mod h1:Ipv4tsdxZRbQyLq9Q1M6gdbkxYzdlrciF2Hi/lS7nWE=
|
||||||
+169
-15
@@ -18,6 +18,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
lpra "github.com/x-grk/longpolling-remote-access"
|
lpra "github.com/x-grk/longpolling-remote-access"
|
||||||
@@ -30,6 +31,7 @@ var Version = lpra.Version
|
|||||||
// Config is the agent's runtime configuration (from env / config.env).
|
// Config is the agent's runtime configuration (from env / config.env).
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Host string // e.g. https://REDACTED:8445
|
Host string // e.g. https://REDACTED:8445
|
||||||
|
Fallback string // the direct address, from the installer (FALLBACK_HOST); may be empty
|
||||||
EnrollSecret string
|
EnrollSecret string
|
||||||
Name string // optional friendly name
|
Name string // optional friendly name
|
||||||
StatePath string // where the enrolled id+token are persisted
|
StatePath string // where the enrolled id+token are persisted
|
||||||
@@ -42,6 +44,8 @@ type Config struct {
|
|||||||
type state struct {
|
type state struct {
|
||||||
AgentID string `json:"agent_id"`
|
AgentID string `json:"agent_id"`
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
|
// Fallback is the direct address as the server told it.
|
||||||
|
Fallback string `json:"fallback,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Agent is a running client.
|
// Agent is a running client.
|
||||||
@@ -50,9 +54,13 @@ type Agent struct {
|
|||||||
http *http.Client
|
http *http.Client
|
||||||
stream *http.Client
|
stream *http.Client
|
||||||
state state
|
state state
|
||||||
|
stateMu sync.Mutex
|
||||||
|
hosts hostState
|
||||||
pollCount int
|
pollCount int
|
||||||
|
|
||||||
// Direct tunnel currently running (see setTunnelAddr).
|
// Direct tunnel currently running (see setTunnelAddr).
|
||||||
|
transport atomic.Value // string: auto, ws or lp, as the server says
|
||||||
|
link atomic.Pointer[wsLink]
|
||||||
tunMu sync.Mutex
|
tunMu sync.Mutex
|
||||||
tunAddr string
|
tunAddr string
|
||||||
tunCancel context.CancelFunc
|
tunCancel context.CancelFunc
|
||||||
@@ -94,11 +102,20 @@ func (a *Agent) Run(ctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if a.state.AgentID == "" || a.state.Token == "" {
|
if a.state.AgentID == "" || a.state.Token == "" {
|
||||||
if err := a.enroll(ctx); err != nil {
|
err := a.enroll(ctx)
|
||||||
|
// The main address out of reach at install: the direct one.
|
||||||
|
if err != nil && a.cfg.Fallback != "" && a.cfg.Fallback != a.cfg.Host {
|
||||||
|
log.Printf("enroll at %s: %v - trying %s", a.cfg.Host, err, a.cfg.Fallback)
|
||||||
|
a.setHost(a.cfg.Fallback)
|
||||||
|
a.hostChanged()
|
||||||
|
err = a.enroll(ctx)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
return fmt.Errorf("enroll: %w", err)
|
return fmt.Errorf("enroll: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
log.Printf("agent %s connected to %s", a.state.AgentID, a.cfg.Host)
|
go a.watchMain(ctx)
|
||||||
|
log.Printf("agent %s connected to %s", a.state.AgentID, a.host())
|
||||||
|
|
||||||
// Direct forward tunnel (low-latency TCP mux + UDP). Its address comes
|
// Direct forward tunnel (low-latency TCP mux + UDP). Its address comes
|
||||||
// from the panel; fall back to the configured one when the server sends
|
// from the panel; fall back to the configured one when the server sends
|
||||||
@@ -107,25 +124,142 @@ func (a *Agent) Run(ctx context.Context) error {
|
|||||||
a.setTunnelAddr(ctx, a.cfg.TunnelAddr)
|
a.setTunnelAddr(ctx, a.cfg.TunnelAddr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The control channel: a WebSocket, or long polls. In "auto" a failed or
|
||||||
|
// dropped WebSocket gives way to long polling for a minute, then is tried
|
||||||
|
// again; "ws" keeps trying the WebSocket; "lp" only polls.
|
||||||
backoff := time.Second
|
backoff := time.Second
|
||||||
for ctx.Err() == nil {
|
for ctx.Err() == nil {
|
||||||
if err := a.pollOnce(ctx); err != nil {
|
if a.mode() == proto.TransportLP {
|
||||||
log.Printf("poll: %v (retry in %s)", err, backoff)
|
a.longPoll(ctx, 0)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
err := a.runWS(ctx)
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Moved to the other address: the WebSocket goes there at once.
|
||||||
|
if a.hostChanged() {
|
||||||
|
backoff = time.Second
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
a.failed()
|
||||||
|
if a.hostChanged() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch a.mode() {
|
||||||
|
case proto.TransportWS:
|
||||||
|
log.Printf("websocket: %v (retry in %s)", err, backoff)
|
||||||
select {
|
select {
|
||||||
case <-time.After(backoff):
|
case <-time.After(backoff):
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if backoff < 15*time.Second {
|
backoff = min(backoff*2, retryCap)
|
||||||
backoff *= 2
|
|
||||||
}
|
|
||||||
continue
|
continue
|
||||||
|
case proto.TransportAuto:
|
||||||
|
log.Printf("websocket: %v - long polling, websocket again in a minute", err)
|
||||||
}
|
}
|
||||||
backoff = time.Second
|
backoff = time.Second
|
||||||
|
a.longPoll(ctx, time.Minute)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mode is the transport the server set for this agent.
|
||||||
|
func (a *Agent) mode() string {
|
||||||
|
if v, ok := a.transport.Load().(string); ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return proto.TransportAuto
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) applyTransport(v string) {
|
||||||
|
switch v {
|
||||||
|
case proto.TransportAuto, proto.TransportWS, proto.TransportLP:
|
||||||
|
if v != a.mode() {
|
||||||
|
log.Printf("transport: %s", v)
|
||||||
|
a.transport.Store(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// longPoll runs control polls: for d, or with d zero until the transport is
|
||||||
|
// no longer long polling.
|
||||||
|
func (a *Agent) longPoll(ctx context.Context, d time.Duration) {
|
||||||
|
until := time.Now().Add(d)
|
||||||
|
backoff := time.Second
|
||||||
|
// The server was out of reach (a network down, a machine asleep): once
|
||||||
|
// a poll gets through again, "auto" tries the WebSocket at once rather
|
||||||
|
// than at the end of the minute - it was the network, not the WebSocket.
|
||||||
|
lost := false
|
||||||
|
for ctx.Err() == nil {
|
||||||
|
if d > 0 && (time.Now().After(until) || a.mode() == proto.TransportWS) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if d == 0 && a.mode() != proto.TransportLP {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Back from out of reach in "auto": a quick request tells when the
|
||||||
|
// server answers again, and the WebSocket is tried then; a poll
|
||||||
|
// would sit held on the server first.
|
||||||
|
if lost && d > 0 {
|
||||||
|
if a.reachable(ctx) {
|
||||||
|
a.reached()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.failed()
|
||||||
|
if a.hostChanged() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-time.After(backoff):
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
backoff = min(backoff*2, retryCap)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// In "auto" the minute is a minute: a poll still held then is cut
|
||||||
|
// short (the server keeps its job for the next one).
|
||||||
|
reqCtx, cancel := ctx, context.CancelFunc(func() {})
|
||||||
|
if d > 0 {
|
||||||
|
reqCtx, cancel = context.WithDeadline(ctx, until)
|
||||||
|
}
|
||||||
|
err := a.pollOnce(ctx, reqCtx)
|
||||||
|
cut := reqCtx.Err() == context.DeadlineExceeded
|
||||||
|
cancel()
|
||||||
|
if err != nil && cut && ctx.Err() == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
a.failed()
|
||||||
|
if a.hostChanged() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("poll: %v (retry in %s)", err, backoff)
|
||||||
|
select {
|
||||||
|
case <-time.After(backoff):
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
backoff = min(backoff*2, retryCap)
|
||||||
|
lost = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
a.reached()
|
||||||
|
backoff = time.Second
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reachable reports whether the server answers a quick request.
|
||||||
|
func (a *Agent) reachable(ctx context.Context) bool {
|
||||||
|
return a.reachableAt(ctx, a.host())
|
||||||
|
}
|
||||||
|
|
||||||
|
// retryCap bounds the wait between attempts to reach the server, so a
|
||||||
|
// machine back from sleep or a network back up is reconnected in seconds.
|
||||||
|
const retryCap = 5 * time.Second
|
||||||
|
|
||||||
func (a *Agent) enroll(ctx context.Context) error {
|
func (a *Agent) enroll(ctx context.Context) error {
|
||||||
host, _ := os.Hostname()
|
host, _ := os.Hostname()
|
||||||
body, _ := json.Marshal(proto.EnrollRequest{
|
body, _ := json.Marshal(proto.EnrollRequest{
|
||||||
@@ -136,7 +270,7 @@ func (a *Agent) enroll(ctx context.Context) error {
|
|||||||
Version: Version,
|
Version: Version,
|
||||||
User: currentUser(),
|
User: currentUser(),
|
||||||
})
|
})
|
||||||
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, a.cfg.Host+"/api/agent/enroll", bytes.NewReader(body))
|
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, a.host()+"/api/agent/enroll", bytes.NewReader(body))
|
||||||
req.Header.Set("X-Enroll-Secret", a.cfg.EnrollSecret)
|
req.Header.Set("X-Enroll-Secret", a.cfg.EnrollSecret)
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
resp, err := a.http.Do(req)
|
resp, err := a.http.Do(req)
|
||||||
@@ -157,8 +291,10 @@ func (a *Agent) enroll(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// pollOnce performs one control long-poll and dispatches any instruction.
|
// pollOnce performs one control long-poll and dispatches any instruction.
|
||||||
func (a *Agent) pollOnce(ctx context.Context) error {
|
// The poll itself runs on reqCtx, which may end early; what it starts runs on
|
||||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, a.cfg.Host+"/api/agent/poll", nil)
|
// ctx.
|
||||||
|
func (a *Agent) pollOnce(ctx, reqCtx context.Context) error {
|
||||||
|
req, _ := http.NewRequestWithContext(reqCtx, http.MethodGet, a.host()+"/api/agent/poll", nil)
|
||||||
a.authHeaders(req)
|
a.authHeaders(req)
|
||||||
// Report the version and our OS user occasionally (not every poll), so the
|
// Report the version and our OS user occasionally (not every poll), so the
|
||||||
// panel can flag outdated agents without extra traffic.
|
// panel can flag outdated agents without extra traffic.
|
||||||
@@ -183,7 +319,15 @@ func (a *Agent) pollOnce(ctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
a.applyTunnelHeader(ctx, resp.Header)
|
a.applyTunnelHeader(ctx, resp.Header)
|
||||||
// Dispatch in the background so we keep polling for more work.
|
a.learnAddrs(resp.Header.Get(proto.MainHeader), resp.Header.Get(proto.FallbackHeader))
|
||||||
|
a.applyTransport(resp.Header.Get(proto.TransportHeader))
|
||||||
|
a.dispatch(ctx, msg)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// dispatch starts what a control message asks for, in the background, so the
|
||||||
|
// control channel keeps listening.
|
||||||
|
func (a *Agent) dispatch(ctx context.Context, msg proto.ControlMessage) {
|
||||||
switch msg.Type {
|
switch msg.Type {
|
||||||
case proto.CtrlOpenTerminal:
|
case proto.CtrlOpenTerminal:
|
||||||
go a.runTerminal(ctx, msg.SessionID, msg.User)
|
go a.runTerminal(ctx, msg.SessionID, msg.User)
|
||||||
@@ -191,8 +335,11 @@ func (a *Agent) pollOnce(ctx context.Context) error {
|
|||||||
go a.runForward(ctx, msg.SessionID, msg.Port)
|
go a.runForward(ctx, msg.SessionID, msg.Port)
|
||||||
case proto.CtrlTask:
|
case proto.CtrlTask:
|
||||||
go a.runTask(ctx, msg.TaskID)
|
go a.runTask(ctx, msg.TaskID)
|
||||||
|
case proto.CtrlOpenSSH:
|
||||||
|
if msg.SSH != nil {
|
||||||
|
go a.runSSH(ctx, msg.SessionID, *msg.SSH)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchConfig asks the server for the settings it hands to agents and reports
|
// fetchConfig asks the server for the settings it hands to agents and reports
|
||||||
@@ -200,20 +347,25 @@ func (a *Agent) pollOnce(ctx context.Context) error {
|
|||||||
func (a *Agent) fetchConfig(ctx context.Context) bool {
|
func (a *Agent) fetchConfig(ctx context.Context) bool {
|
||||||
ctx2, cancel := context.WithTimeout(ctx, 10*time.Second)
|
ctx2, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
req, _ := http.NewRequestWithContext(ctx2, http.MethodGet, a.cfg.Host+"/api/agent/config", nil)
|
req, _ := http.NewRequestWithContext(ctx2, http.MethodGet, a.host()+"/api/agent/config", nil)
|
||||||
a.authHeaders(req)
|
a.authHeaders(req)
|
||||||
resp, err := a.http.Do(req)
|
resp, err := a.http.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
a.applyTransport(resp.Header.Get(proto.TransportHeader))
|
||||||
|
a.learnAddrs(resp.Header.Get(proto.MainHeader), resp.Header.Get(proto.FallbackHeader))
|
||||||
return a.applyTunnelHeader(ctx, resp.Header)
|
return a.applyTunnelHeader(ctx, resp.Header)
|
||||||
}
|
}
|
||||||
|
|
||||||
// applyTunnelHeader follows the tunnel address the server advertises as
|
// applyTunnelHeader follows the tunnel address the server advertises as
|
||||||
// "host:port"; an empty host means the host we already reach the server on.
|
// "host:port"; an empty host means the host we already reach the server on.
|
||||||
func (a *Agent) applyTunnelHeader(ctx context.Context, h http.Header) bool {
|
func (a *Agent) applyTunnelHeader(ctx context.Context, h http.Header) bool {
|
||||||
v := h.Get("X-Tunnel-Addr")
|
return a.applyTunnelAddr(ctx, h.Get("X-Tunnel-Addr"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) applyTunnelAddr(ctx context.Context, v string) bool {
|
||||||
if v == "" || a.cfg.TunnelPinned {
|
if v == "" || a.cfg.TunnelPinned {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -222,7 +374,7 @@ func (a *Agent) applyTunnelHeader(ctx context.Context, h http.Header) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if host == "" {
|
if host == "" {
|
||||||
u, err := url.Parse(a.cfg.Host)
|
u, err := url.Parse(a.host())
|
||||||
if err != nil || u.Hostname() == "" {
|
if err != nil || u.Hostname() == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -277,7 +429,9 @@ func (a *Agent) saveState() error {
|
|||||||
if err := os.MkdirAll(filepath.Dir(a.cfg.StatePath), 0o700); err != nil {
|
if err := os.MkdirAll(filepath.Dir(a.cfg.StatePath), 0o700); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
a.stateMu.Lock()
|
||||||
b, _ := json.MarshalIndent(a.state, "", " ")
|
b, _ := json.MarshalIndent(a.state, "", " ")
|
||||||
|
a.stateMu.Unlock()
|
||||||
return os.WriteFile(a.cfg.StatePath, b, 0o600)
|
return os.WriteFile(a.cfg.StatePath, b, 0o600)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,18 @@ func dispatchFS(env proto.TaskEnvelope) (any, error) {
|
|||||||
var a proto.FSZipArgs
|
var a proto.FSZipArgs
|
||||||
_ = json.Unmarshal(env.Args, &a)
|
_ = json.Unmarshal(env.Args, &a)
|
||||||
return fsZip(a.Paths, a.Dest)
|
return fsZip(a.Paths, a.Dest)
|
||||||
|
case proto.TaskFSStat:
|
||||||
|
var a proto.FSPathArgs
|
||||||
|
_ = json.Unmarshal(env.Args, &a)
|
||||||
|
return fsStat(a.Path)
|
||||||
|
case proto.TaskFSChmod:
|
||||||
|
var a proto.FSChmodArgs
|
||||||
|
_ = json.Unmarshal(env.Args, &a)
|
||||||
|
return fsChmod(a)
|
||||||
|
case proto.TaskFSCopy:
|
||||||
|
var a proto.FSCopyArgs
|
||||||
|
_ = json.Unmarshal(env.Args, &a)
|
||||||
|
return fsCopy(a)
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("неизвестная операция %q", env.Type)
|
return nil, fmt.Errorf("неизвестная операция %q", env.Type)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fsCopy copies or moves Src to Dst, following Conflict when Dst exists.
|
||||||
|
func fsCopy(a proto.FSCopyArgs) (any, error) {
|
||||||
|
src, dst := filepath.Clean(a.Src), filepath.Clean(a.Dst)
|
||||||
|
if a.Src == "" || a.Dst == "" {
|
||||||
|
return nil, errors.New("не указан путь")
|
||||||
|
}
|
||||||
|
if src == dst {
|
||||||
|
return nil, errors.New("откуда и куда - одно и то же")
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(dst, src+string(filepath.Separator)) {
|
||||||
|
return nil, errors.New("нельзя положить папку внутрь неё самой")
|
||||||
|
}
|
||||||
|
if _, err := os.Lstat(src); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Dir(dst)); err != nil {
|
||||||
|
return nil, fmt.Errorf("нет папки %s", filepath.Dir(dst))
|
||||||
|
}
|
||||||
|
c := &copier{move: a.Move, conflict: a.Conflict}
|
||||||
|
if err := c.place(src, dst); err != nil {
|
||||||
|
return map[string]int{"done": c.done, "skipped": c.skipped}, err
|
||||||
|
}
|
||||||
|
return map[string]int{"done": c.done, "skipped": c.skipped}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type copier struct {
|
||||||
|
move bool
|
||||||
|
conflict string
|
||||||
|
done, skipped int
|
||||||
|
}
|
||||||
|
|
||||||
|
// place puts src at dst. Two folders merge when the rule allows it; anything
|
||||||
|
// else that is in the way stops, is replaced or is kept, by the rule.
|
||||||
|
func (c *copier) place(src, dst string) error {
|
||||||
|
si, err := os.Lstat(src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
di, err := os.Lstat(dst)
|
||||||
|
if err == nil {
|
||||||
|
if c.conflict != "replace" && c.conflict != "skip" {
|
||||||
|
return fmt.Errorf("%s уже существует", dst)
|
||||||
|
}
|
||||||
|
if si.IsDir() && di.IsDir() {
|
||||||
|
entries, err := os.ReadDir(src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
if err := c.place(filepath.Join(src, e.Name()), filepath.Join(dst, e.Name())); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.move {
|
||||||
|
// Whatever was skipped stays where it was.
|
||||||
|
_ = os.Remove(src)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if c.conflict == "skip" {
|
||||||
|
c.skipped++
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := os.RemoveAll(dst); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else if !errors.Is(err, fs.ErrNotExist) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if c.move {
|
||||||
|
err := os.Rename(src, dst)
|
||||||
|
if err == nil {
|
||||||
|
c.done++
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Another file system: copy, then take the original away.
|
||||||
|
if !errors.Is(err, syscall.EXDEV) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := c.copyTree(src, dst); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.RemoveAll(src)
|
||||||
|
}
|
||||||
|
return c.copyTree(src, dst)
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyTree copies a file, a symlink or a whole folder, keeping modes and
|
||||||
|
// times.
|
||||||
|
func (c *copier) copyTree(src, dst string) error {
|
||||||
|
return filepath.WalkDir(src, func(p string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rel, _ := filepath.Rel(src, p)
|
||||||
|
to := filepath.Join(dst, rel)
|
||||||
|
info, err := os.Lstat(p)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case info.Mode()&fs.ModeSymlink != 0:
|
||||||
|
link, err := os.Readlink(p)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Symlink(link, to); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case info.IsDir():
|
||||||
|
if err := os.Mkdir(to, info.Mode().Perm()); err != nil && !errors.Is(err, fs.ErrExist) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case info.Mode().IsRegular():
|
||||||
|
if err := copyFile(p, to, info); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil // sockets, devices: not copied
|
||||||
|
}
|
||||||
|
c.done++
|
||||||
|
if st, ok := info.Sys().(*syscall.Stat_t); ok {
|
||||||
|
_ = os.Lchown(to, int(st.Uid), int(st.Gid))
|
||||||
|
}
|
||||||
|
if info.Mode()&fs.ModeSymlink == 0 {
|
||||||
|
_ = os.Chtimes(to, info.ModTime(), info.ModTime())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyFile(src, dst string, info fs.FileInfo) error {
|
||||||
|
in, err := os.Open(src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer in.Close()
|
||||||
|
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode().Perm())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(out, in); err != nil {
|
||||||
|
out.Close()
|
||||||
|
os.Remove(dst)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := out.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Chmod(dst, info.Mode()&(fs.ModePerm|fs.ModeSetuid|fs.ModeSetgid|fs.ModeSticky))
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"os/user"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fsStat reports a file's properties, following a symlink to what it points
|
||||||
|
// at, and the user and group names to choose an owner from.
|
||||||
|
func fsStat(path string) (proto.FSStatResult, error) {
|
||||||
|
res := proto.FSStatResult{Path: path}
|
||||||
|
if li, err := os.Lstat(path); err == nil && li.Mode()&os.ModeSymlink != 0 {
|
||||||
|
res.Link, _ = os.Readlink(path)
|
||||||
|
}
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
res.IsDir, res.Size, res.MTime = info.IsDir(), info.Size(), info.ModTime().Unix()
|
||||||
|
if st, ok := info.Sys().(*syscall.Stat_t); ok {
|
||||||
|
res.Mode = uint32(st.Mode) & 07777
|
||||||
|
res.UID, res.GID = int(st.Uid), int(st.Gid)
|
||||||
|
} else {
|
||||||
|
res.Mode = uint32(info.Mode().Perm())
|
||||||
|
}
|
||||||
|
res.Owner, res.Group = strconv.Itoa(res.UID), strconv.Itoa(res.GID)
|
||||||
|
if u, err := user.LookupId(res.Owner); err == nil {
|
||||||
|
res.Owner = u.Username
|
||||||
|
}
|
||||||
|
if g, err := user.LookupGroupId(res.Group); err == nil {
|
||||||
|
res.Group = g.Name
|
||||||
|
}
|
||||||
|
res.Users, res.Groups = namesIn("/etc/passwd"), namesIn("/etc/group")
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// namesIn lists the first field of a passwd- or group-style file.
|
||||||
|
func namesIn(file string) []string {
|
||||||
|
f, err := os.Open(file)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
var out []string
|
||||||
|
sc := bufio.NewScanner(f)
|
||||||
|
for sc.Scan() && len(out) < 1000 {
|
||||||
|
line := sc.Text()
|
||||||
|
if line == "" || line[0] == '#' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if name, _, ok := strings.Cut(line, ":"); ok && name != "" {
|
||||||
|
out = append(out, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookupID resolves a user or group given by name or number; -1 for none.
|
||||||
|
func lookupID(name string, group bool) (int, error) {
|
||||||
|
if name == "" {
|
||||||
|
return -1, nil
|
||||||
|
}
|
||||||
|
if n, err := strconv.Atoi(name); err == nil && n >= 0 {
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
if group {
|
||||||
|
g, err := user.LookupGroup(name)
|
||||||
|
if err != nil {
|
||||||
|
return -1, fmt.Errorf("группа %s не найдена", name)
|
||||||
|
}
|
||||||
|
return strconv.Atoi(g.Gid)
|
||||||
|
}
|
||||||
|
u, err := user.Lookup(name)
|
||||||
|
if err != nil {
|
||||||
|
return -1, fmt.Errorf("пользователь %s не найден", name)
|
||||||
|
}
|
||||||
|
return strconv.Atoi(u.Uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fsChmod sets mode, owner and group on a path, and with Recursive on all
|
||||||
|
// that is inside a folder. Symlinks inside are not followed.
|
||||||
|
func fsChmod(a proto.FSChmodArgs) (any, error) {
|
||||||
|
if a.Path == "" {
|
||||||
|
return nil, errors.New("не указан путь")
|
||||||
|
}
|
||||||
|
if a.Recursive && filepath.Clean(a.Path) == "/" {
|
||||||
|
return nil, errors.New("отказ: рекурсивно для корня нельзя")
|
||||||
|
}
|
||||||
|
if a.Mode != nil && *a.Mode > 07777 {
|
||||||
|
return nil, errors.New("неверные права")
|
||||||
|
}
|
||||||
|
uid, err := lookupID(a.Owner, false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
gid, err := lookupID(a.Group, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
changed, failed := 0, 0
|
||||||
|
var errs []string
|
||||||
|
// The path itself always gets the change; Only picks among what is in it.
|
||||||
|
apply := func(p string, isDir, isLink, self bool) {
|
||||||
|
if !self && ((a.Only == "files" && isDir) || (a.Only == "dirs" && !isDir)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
if uid >= 0 || gid >= 0 {
|
||||||
|
err = os.Lchown(p, uid, gid)
|
||||||
|
}
|
||||||
|
// A symlink has no mode of its own: chmod would change its target.
|
||||||
|
if err == nil && a.Mode != nil && !isLink {
|
||||||
|
err = syscall.Chmod(p, *a.Mode)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
failed++
|
||||||
|
if len(errs) < 5 {
|
||||||
|
errs = append(errs, err.Error())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
changed++
|
||||||
|
}
|
||||||
|
info, err := os.Stat(a.Path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !a.Recursive || !info.IsDir() {
|
||||||
|
apply(a.Path, info.IsDir(), false, true)
|
||||||
|
} else {
|
||||||
|
err = filepath.WalkDir(a.Path, func(p string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
failed++
|
||||||
|
if len(errs) < 5 {
|
||||||
|
errs = append(errs, err.Error())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
apply(p, d.IsDir(), d.Type()&fs.ModeSymlink != 0, p == a.Path)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res := map[string]any{"changed": changed, "failed": failed, "errors": errs}
|
||||||
|
if failed > 0 && changed == 0 {
|
||||||
|
return res, errors.New(errs[0])
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
@@ -41,7 +41,7 @@ type errXferGone string
|
|||||||
func (e errXferGone) Error() string { return string(e) }
|
func (e errXferGone) Error() string { return string(e) }
|
||||||
|
|
||||||
func (a *Agent) xferURL(id string, i int) string {
|
func (a *Agent) xferURL(id string, i int) string {
|
||||||
return a.cfg.Host + "/api/agent/fs/xfer/" + id + "/" + strconv.Itoa(i)
|
return a.host() + "/api/agent/fs/xfer/" + id + "/" + strconv.Itoa(i)
|
||||||
}
|
}
|
||||||
|
|
||||||
// xferRequest makes one request for a piece, trying again over a failing link.
|
// xferRequest makes one request for a piece, trying again over a failing link.
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -54,6 +54,15 @@ func (a *Agent) bridgeStream(parent context.Context, sid string, rw io.ReadWrite
|
|||||||
ctx, cancel := context.WithCancel(parent)
|
ctx, cancel := context.WithCancel(parent)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
defer rw.Close()
|
defer rw.Close()
|
||||||
|
defer func() {
|
||||||
|
if l := a.link.Load(); l != nil {
|
||||||
|
l.unsubscribe(sid)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
// A session closed from the panel (a tab reloaded, a window shut) ends
|
||||||
|
// the stream at once, not on its next output: an idle shell would live
|
||||||
|
// on until the agent stopped.
|
||||||
|
go func() { <-ctx.Done(); rw.Close() }()
|
||||||
|
|
||||||
// Downstream: pull input frames and write them to the stream.
|
// Downstream: pull input frames and write them to the stream.
|
||||||
go func() {
|
go func() {
|
||||||
@@ -84,7 +93,22 @@ func (a *Agent) bridgeStream(parent context.Context, sid string, rw io.ReadWrite
|
|||||||
// pollIn performs one downstream long-poll and applies the frames.
|
// pollIn performs one downstream long-poll and applies the frames.
|
||||||
// Returns false when the session is gone and the loop should stop.
|
// Returns false when the session is gone and the loop should stop.
|
||||||
func (a *Agent) pollIn(ctx context.Context, sid string, w io.Writer, onResize func(cols, rows uint16)) bool {
|
func (a *Agent) pollIn(ctx context.Context, sid string, w io.Writer, onResize func(cols, rows uint16)) bool {
|
||||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, a.cfg.Host+"/api/agent/term/"+sid+"/in", nil)
|
// On the WebSocket the input comes pushed; if it drops, by a long poll.
|
||||||
|
if l := a.link.Load(); l != nil {
|
||||||
|
if ch := l.subscribe(sid); ch != nil {
|
||||||
|
select {
|
||||||
|
case in := <-ch:
|
||||||
|
if in.gone {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return applyFrames(in.frames, w, onResize)
|
||||||
|
case <-l.done:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, a.host()+"/api/agent/term/"+sid+"/in", nil)
|
||||||
a.authHeaders(req)
|
a.authHeaders(req)
|
||||||
resp, err := a.http.Do(req)
|
resp, err := a.http.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -101,7 +125,13 @@ func (a *Agent) pollIn(ctx context.Context, sid string, w io.Writer, onResize fu
|
|||||||
if err := json.NewDecoder(resp.Body).Decode(&batch); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&batch); err != nil {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
for _, f := range batch.Frames {
|
return applyFrames(batch.Frames, w, onResize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyFrames writes a session's input to its stream; false once the panel
|
||||||
|
// closed it.
|
||||||
|
func applyFrames(frames []proto.InFrame, w io.Writer, onResize func(cols, rows uint16)) bool {
|
||||||
|
for _, f := range frames {
|
||||||
switch f.Type {
|
switch f.Type {
|
||||||
case proto.FrameStdin:
|
case proto.FrameStdin:
|
||||||
if data, err := b64.DecodeString(f.Data); err == nil {
|
if data, err := b64.DecodeString(f.Data); err == nil {
|
||||||
@@ -120,8 +150,13 @@ func (a *Agent) pollIn(ctx context.Context, sid string, w io.Writer, onResize fu
|
|||||||
|
|
||||||
// postOut sends one upstream message. Returns false if the session is gone.
|
// postOut sends one upstream message. Returns false if the session is gone.
|
||||||
func (a *Agent) postOut(ctx context.Context, sid string, msg proto.OutMessage) bool {
|
func (a *Agent) postOut(ctx context.Context, sid string, msg proto.OutMessage) bool {
|
||||||
|
if l := a.link.Load(); l != nil {
|
||||||
|
if l.send(proto.WSMsg{T: "out", SID: sid, Out: &msg}) == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
body, _ := json.Marshal(msg)
|
body, _ := json.Marshal(msg)
|
||||||
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, a.cfg.Host+"/api/agent/term/"+sid+"/out", bytes.NewReader(body))
|
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, a.host()+"/api/agent/term/"+sid+"/out", bytes.NewReader(body))
|
||||||
a.authHeaders(req)
|
a.authHeaders(req)
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
resp, err := a.http.Do(req)
|
resp, err := a.http.Do(req)
|
||||||
|
|||||||
@@ -0,0 +1,580 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
|
||||||
|
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A file or folder goes to another machine as a tar stream: packed here on
|
||||||
|
// the fly, then either posted in pieces to a relay on the server for the
|
||||||
|
// other agent to unpack, or written by SSH straight into tar on that host.
|
||||||
|
// Nothing is staged on disk on either side.
|
||||||
|
|
||||||
|
// shipMeter counts what has gone out, for the panel's progress bar.
|
||||||
|
type shipMeter struct {
|
||||||
|
total, done atomic.Int64
|
||||||
|
files atomic.Int64
|
||||||
|
note atomic.Value // string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *shipMeter) snapshot() proto.ShipProgress {
|
||||||
|
n, _ := m.note.Load().(string)
|
||||||
|
return proto.ShipProgress{Total: m.total.Load(), Done: m.done.Load(), Files: int(m.files.Load()), Note: n}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reportShip posts the progress to the job until ctx ends; the server
|
||||||
|
// answering 410 means the job was cancelled, and cancel is called.
|
||||||
|
func (a *Agent) reportShip(ctx context.Context, job string, m *shipMeter, cancel func()) {
|
||||||
|
if job == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t := time.NewTicker(700 * time.Millisecond)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
body, _ := json.Marshal(m.snapshot())
|
||||||
|
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, a.host()+"/api/agent/fs/job/"+job+"/progress", bytes.NewReader(body))
|
||||||
|
a.authHeaders(req)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if resp, err := a.http.Do(req); err == nil {
|
||||||
|
io.Copy(io.Discard, resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode == http.StatusGone {
|
||||||
|
cancel()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// shipName checks the name the thing gets at the other end.
|
||||||
|
func shipName(name string) error {
|
||||||
|
if name == "" || name == "." || name == ".." || strings.ContainsAny(name, "/\x00") {
|
||||||
|
return errors.New("недопустимое имя")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// treeSize is how many bytes of files are under p.
|
||||||
|
func treeSize(ctx context.Context, p string) int64 {
|
||||||
|
var n int64
|
||||||
|
_ = filepath.WalkDir(p, func(_ string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil || ctx.Err() != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if d.Type().IsRegular() {
|
||||||
|
if info, err := d.Info(); err == nil {
|
||||||
|
n += info.Size()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// packTar writes src as a tar stream whose entries start at name. Owners are
|
||||||
|
// left out: the numbers mean other people on another machine.
|
||||||
|
func packTar(ctx context.Context, w io.Writer, src, name string, m *shipMeter) error {
|
||||||
|
tw := tar.NewWriter(w)
|
||||||
|
err := filepath.WalkDir(src, func(p string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
info, err := os.Lstat(p)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
link := ""
|
||||||
|
switch {
|
||||||
|
case info.Mode()&fs.ModeSymlink != 0:
|
||||||
|
if link, err = os.Readlink(p); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case info.IsDir(), info.Mode().IsRegular():
|
||||||
|
default:
|
||||||
|
return nil // sockets, devices, pipes: not sent
|
||||||
|
}
|
||||||
|
hdr, err := tar.FileInfoHeader(info, link)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rel, _ := filepath.Rel(src, p)
|
||||||
|
hdr.Name = name
|
||||||
|
if rel != "." {
|
||||||
|
hdr.Name = path.Join(name, filepath.ToSlash(rel))
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
hdr.Name += "/"
|
||||||
|
}
|
||||||
|
hdr.Uid, hdr.Gid, hdr.Uname, hdr.Gname = 0, 0, "", ""
|
||||||
|
hdr.Format = tar.FormatPAX
|
||||||
|
if err := tw.WriteHeader(hdr); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if info.Mode().IsRegular() {
|
||||||
|
f, err := os.Open(p)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = io.CopyN(tw, &countReader{r: f, n: &m.done}, hdr.Size)
|
||||||
|
f.Close()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s: %v", p, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.files.Add(1)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tw.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
type countReader struct {
|
||||||
|
r io.Reader
|
||||||
|
n *atomic.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *countReader) Read(p []byte) (int, error) {
|
||||||
|
n, err := c.r.Read(p)
|
||||||
|
c.n.Add(int64(n))
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// doShipOut sends Src to the other machine: by the relay, or by SSH.
|
||||||
|
func (a *Agent) doShipOut(parent context.Context, env proto.TaskEnvelope) proto.TaskResult {
|
||||||
|
var args proto.FSShipArgs
|
||||||
|
if err := json.Unmarshal(env.Args, &args); err != nil {
|
||||||
|
return proto.TaskResult{Error: err.Error()}
|
||||||
|
}
|
||||||
|
if err := shipName(args.Name); err != nil {
|
||||||
|
return proto.TaskResult{Error: err.Error()}
|
||||||
|
}
|
||||||
|
src := filepath.Clean(args.Src)
|
||||||
|
if _, err := os.Lstat(src); err != nil {
|
||||||
|
return proto.TaskResult{Error: err.Error()}
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(parent)
|
||||||
|
defer cancel()
|
||||||
|
m := &shipMeter{}
|
||||||
|
m.note.Store("подсчёт")
|
||||||
|
go a.reportShip(ctx, args.Job, m, cancel)
|
||||||
|
m.total.Store(treeSize(ctx, src))
|
||||||
|
|
||||||
|
var err error
|
||||||
|
if args.SSH != nil {
|
||||||
|
err = a.shipSSH(ctx, args, src, m)
|
||||||
|
} else {
|
||||||
|
m.note.Store("")
|
||||||
|
err = a.shipRelay(ctx, args, src, m)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() != nil && parent.Err() == nil {
|
||||||
|
err = errors.New("отменено")
|
||||||
|
}
|
||||||
|
return proto.TaskResult{Error: err.Error()}
|
||||||
|
}
|
||||||
|
res := map[string]any{"files": m.files.Load(), "bytes": m.done.Load()}
|
||||||
|
if args.Move && args.SSH != nil {
|
||||||
|
if err := os.RemoveAll(src); err != nil {
|
||||||
|
return proto.TaskResult{Error: "отправлено, но здесь не удалилось: " + err.Error()}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(res)
|
||||||
|
return proto.TaskResult{OK: true, Data: data}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) relayURL(id string, i int) string {
|
||||||
|
return a.host() + "/api/agent/fs/relay/" + id + "/" + strconv.Itoa(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
// shipRelay posts the tar stream in pieces, a few at once; the last carries
|
||||||
|
// X-Last.
|
||||||
|
func (a *Agent) shipRelay(ctx context.Context, args proto.FSShipArgs, src string, m *shipMeter) error {
|
||||||
|
if args.Relay == "" || args.Chunk <= 0 {
|
||||||
|
return errors.New("не задана передача")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(ctx)
|
||||||
|
defer cancel()
|
||||||
|
pr, pw := io.Pipe()
|
||||||
|
go func() { pw.CloseWithError(packTar(ctx, pw, src, args.Name, m)) }()
|
||||||
|
defer pr.Close()
|
||||||
|
|
||||||
|
type piece struct {
|
||||||
|
i int
|
||||||
|
data []byte
|
||||||
|
last bool
|
||||||
|
}
|
||||||
|
ch := make(chan piece, 1)
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
first error
|
||||||
|
wg sync.WaitGroup
|
||||||
|
)
|
||||||
|
setErr := func(err error) {
|
||||||
|
mu.Lock()
|
||||||
|
if first == nil {
|
||||||
|
first = err
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
for w := 0; w < xferWorkers; w++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for p := range ch {
|
||||||
|
err := a.xferRequest(ctx, func(ctx context.Context) (*http.Request, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.relayURL(args.Relay, p.i), bytes.NewReader(p.data))
|
||||||
|
if err == nil {
|
||||||
|
req.Header.Set("Content-Type", "application/octet-stream")
|
||||||
|
if p.last {
|
||||||
|
req.Header.Set("X-Last", "1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return req, err
|
||||||
|
}, func(*http.Response) error { return nil })
|
||||||
|
if err != nil {
|
||||||
|
setErr(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
for i := 0; ; i++ {
|
||||||
|
buf := make([]byte, args.Chunk)
|
||||||
|
n, err := io.ReadFull(pr, buf)
|
||||||
|
last := err == io.EOF || err == io.ErrUnexpectedEOF
|
||||||
|
if err != nil && !last {
|
||||||
|
setErr(err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case ch <- piece{i, buf[:n], last}:
|
||||||
|
case <-ctx.Done():
|
||||||
|
}
|
||||||
|
if last || ctx.Err() != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
close(ch)
|
||||||
|
wg.Wait()
|
||||||
|
if first != nil {
|
||||||
|
var gone errXferGone
|
||||||
|
if errors.As(first, &gone) {
|
||||||
|
return errors.New(string(gone))
|
||||||
|
}
|
||||||
|
return first
|
||||||
|
}
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// doShipIn takes the tar stream from the relay and unpacks it into Dir.
|
||||||
|
func (a *Agent) doShipIn(ctx context.Context, env proto.TaskEnvelope) proto.TaskResult {
|
||||||
|
var args proto.FSShipArgs
|
||||||
|
if err := json.Unmarshal(env.Args, &args); err != nil {
|
||||||
|
return proto.TaskResult{Error: err.Error()}
|
||||||
|
}
|
||||||
|
if err := shipName(args.Name); err != nil {
|
||||||
|
return proto.TaskResult{Error: err.Error()}
|
||||||
|
}
|
||||||
|
dir := filepath.Clean(args.Dir)
|
||||||
|
if _, err := os.Lstat(filepath.Join(dir, args.Name)); err == nil && args.Conflict != "replace" && args.Conflict != "skip" {
|
||||||
|
return proto.TaskResult{Error: filepath.Join(dir, args.Name) + " уже существует"}
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return proto.TaskResult{Error: err.Error()}
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(ctx)
|
||||||
|
defer cancel()
|
||||||
|
pr, pw := io.Pipe()
|
||||||
|
go func() {
|
||||||
|
for i := 0; ; i++ {
|
||||||
|
var data []byte
|
||||||
|
var last bool
|
||||||
|
err := a.xferRequest(ctx, func(ctx context.Context) (*http.Request, error) {
|
||||||
|
return http.NewRequestWithContext(ctx, http.MethodGet, a.relayURL(args.Relay, i), nil)
|
||||||
|
}, func(resp *http.Response) error {
|
||||||
|
b, err := io.ReadAll(io.LimitReader(resp.Body, int64(args.Chunk)+1))
|
||||||
|
data, last = b, resp.Header.Get("X-Last") != ""
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
_, err = pw.Write(data)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
pw.CloseWithError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if last {
|
||||||
|
pw.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
u := &unpacker{dir: dir, name: args.Name, conflict: args.Conflict}
|
||||||
|
if os.Geteuid() == 0 {
|
||||||
|
// New things belong to whoever owns the folder they land in.
|
||||||
|
if info, err := os.Stat(dir); err == nil {
|
||||||
|
if st, ok := info.Sys().(*syscall.Stat_t); ok {
|
||||||
|
u.uid, u.gid, u.chown = int(st.Uid), int(st.Gid), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err := u.unpack(tar.NewReader(pr))
|
||||||
|
if err == nil {
|
||||||
|
// Whatever follows the archive's end is read off, so the sender
|
||||||
|
// finishes.
|
||||||
|
_, err = io.Copy(io.Discard, pr)
|
||||||
|
}
|
||||||
|
pr.CloseWithError(errors.New("распаковка закончена"))
|
||||||
|
if err != nil {
|
||||||
|
var gone errXferGone
|
||||||
|
if errors.As(err, &gone) {
|
||||||
|
err = errors.New(string(gone))
|
||||||
|
}
|
||||||
|
return proto.TaskResult{Error: err.Error()}
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(map[string]int{"done": u.done, "skipped": u.skipped})
|
||||||
|
return proto.TaskResult{OK: true, Data: data}
|
||||||
|
}
|
||||||
|
|
||||||
|
// unpacker writes a tar stream into dir, every entry under name, following
|
||||||
|
// the conflict rule for what is there already: folders merge, and files are
|
||||||
|
// replaced or kept.
|
||||||
|
type unpacker struct {
|
||||||
|
dir, name, conflict string
|
||||||
|
uid, gid int
|
||||||
|
chown bool
|
||||||
|
done, skipped int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *unpacker) unpack(tr *tar.Reader) error {
|
||||||
|
var skipUnder string
|
||||||
|
for {
|
||||||
|
hdr, err := tr.Next()
|
||||||
|
if err == io.EOF {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
clean := path.Clean(hdr.Name)
|
||||||
|
if clean != u.name && !strings.HasPrefix(clean, u.name+"/") {
|
||||||
|
return fmt.Errorf("в архиве чужой путь %q", hdr.Name)
|
||||||
|
}
|
||||||
|
target := filepath.Join(u.dir, filepath.FromSlash(clean))
|
||||||
|
if skipUnder != "" && strings.HasPrefix(target, skipUnder) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
have, err := os.Lstat(target)
|
||||||
|
exists := err == nil
|
||||||
|
if exists && !(hdr.Typeflag == tar.TypeDir && have.IsDir()) {
|
||||||
|
switch u.conflict {
|
||||||
|
case "skip":
|
||||||
|
u.skipped++
|
||||||
|
if hdr.Typeflag == tar.TypeDir {
|
||||||
|
skipUnder = target + string(filepath.Separator)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
case "replace":
|
||||||
|
if have.IsDir() || hdr.Typeflag != tar.TypeReg {
|
||||||
|
if err := os.RemoveAll(target); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("%s уже существует", target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mode := os.FileMode(hdr.Mode).Perm()
|
||||||
|
switch hdr.Typeflag {
|
||||||
|
case tar.TypeDir:
|
||||||
|
if !exists || !have.IsDir() {
|
||||||
|
if err := os.MkdirAll(target, mode|0o700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_ = os.Chmod(target, mode)
|
||||||
|
u.own(target)
|
||||||
|
}
|
||||||
|
case tar.TypeReg:
|
||||||
|
if err := u.writeFile(target, tr, mode, hdr.ModTime); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case tar.TypeSymlink:
|
||||||
|
if err := os.Symlink(hdr.Linkname, target); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u.own(target)
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
u.done++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeFile writes next to the target first and puts it in place when all of
|
||||||
|
// it is there.
|
||||||
|
func (u *unpacker) writeFile(target string, r io.Reader, mode os.FileMode, mtime time.Time) error {
|
||||||
|
tmp, err := os.CreateTemp(filepath.Dir(target), "."+filepath.Base(target)+".lpra-*")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(tmp, r); err != nil {
|
||||||
|
tmp.Close()
|
||||||
|
os.Remove(tmp.Name())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tmp.Close(); err != nil {
|
||||||
|
os.Remove(tmp.Name())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_ = os.Chmod(tmp.Name(), mode)
|
||||||
|
_ = os.Chtimes(tmp.Name(), mtime, mtime)
|
||||||
|
u.own(tmp.Name())
|
||||||
|
if err := os.Rename(tmp.Name(), target); err != nil {
|
||||||
|
os.Remove(tmp.Name())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *unpacker) own(p string) {
|
||||||
|
if u.chown {
|
||||||
|
_ = os.Lchown(p, u.uid, u.gid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// shq quotes s for a POSIX shell.
|
||||||
|
func shq(s string) string { return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" }
|
||||||
|
|
||||||
|
// shipSSH writes the tar stream into tar on the SSH host, in Dir.
|
||||||
|
func (a *Agent) shipSSH(ctx context.Context, args proto.FSShipArgs, src string, m *shipMeter) error {
|
||||||
|
client, _, err := a.sshConnect(ctx, *args.SSH, func(_, note string) { m.note.Store(note) })
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
go func() { <-ctx.Done(); client.Close() }()
|
||||||
|
dir := args.Dir
|
||||||
|
if dir == "" {
|
||||||
|
dir = "."
|
||||||
|
}
|
||||||
|
target := strings.TrimSuffix(dir, "/") + "/" + args.Name
|
||||||
|
|
||||||
|
// What tar is there, and whether the target is.
|
||||||
|
m.note.Store("проверка на той стороне")
|
||||||
|
out, err := sshRun(client, "command -v tar >/dev/null 2>&1 || echo LPRA_NOTAR; tar --version 2>/dev/null | head -n 1; test -e "+shq(target)+" -o -L "+shq(target)+" && echo LPRA_EXISTS")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "LPRA_NOTAR") {
|
||||||
|
return errors.New("на той стороне нет tar")
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "LPRA_EXISTS") && args.Conflict != "replace" && args.Conflict != "skip" {
|
||||||
|
return errors.New(target + " там уже существует")
|
||||||
|
}
|
||||||
|
gnu := strings.Contains(out, "GNU tar")
|
||||||
|
flags := ""
|
||||||
|
switch args.Conflict {
|
||||||
|
case "skip":
|
||||||
|
if gnu {
|
||||||
|
flags = " --skip-old-files"
|
||||||
|
} else {
|
||||||
|
flags = " -k"
|
||||||
|
}
|
||||||
|
case "replace":
|
||||||
|
if gnu {
|
||||||
|
flags = " --overwrite"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sess, err := client.NewSession()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer sess.Close()
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
sess.Stderr = &limitBuf{b: &stderr, max: 4096}
|
||||||
|
stdin, err := sess.StdinPipe()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cmd := "mkdir -p -- " + shq(dir) + " && cd -- " + shq(dir) + " && tar -xf -" + flags
|
||||||
|
if err := sess.Start(cmd); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m.note.Store("")
|
||||||
|
packErr := packTar(ctx, stdin, src, args.Name, m)
|
||||||
|
stdin.Close()
|
||||||
|
waitErr := sess.Wait()
|
||||||
|
msg := strings.TrimSpace(stderr.String())
|
||||||
|
if packErr != nil {
|
||||||
|
if msg != "" {
|
||||||
|
return fmt.Errorf("%v (%s)", packErr, msg)
|
||||||
|
}
|
||||||
|
return packErr
|
||||||
|
}
|
||||||
|
if waitErr != nil {
|
||||||
|
var ee *ssh.ExitError
|
||||||
|
if errors.As(waitErr, &ee) && msg != "" {
|
||||||
|
return errors.New("tar на той стороне: " + msg)
|
||||||
|
}
|
||||||
|
return waitErr
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sshRun runs a command and returns its output.
|
||||||
|
func sshRun(c *ssh.Client, cmd string) (string, error) {
|
||||||
|
s, err := c.NewSession()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
out, err := s.Output(cmd)
|
||||||
|
var ee *ssh.ExitError
|
||||||
|
if errors.As(err, &ee) {
|
||||||
|
err = nil // the checks may exit non-zero; what they print is the answer
|
||||||
|
}
|
||||||
|
return string(out), err
|
||||||
|
}
|
||||||
|
|
||||||
|
type limitBuf struct {
|
||||||
|
b *bytes.Buffer
|
||||||
|
max int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *limitBuf) Write(p []byte) (int, error) {
|
||||||
|
if room := l.max - l.b.Len(); room > 0 {
|
||||||
|
l.b.Write(p[:min(len(p), room)])
|
||||||
|
}
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
@@ -119,7 +119,7 @@ func (a *Agent) speedDown(ctx context.Context, path string, window time.Duration
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *Agent) speedRequest(ctx context.Context, method, path string, body io.Reader) (*http.Response, error) {
|
func (a *Agent) speedRequest(ctx context.Context, method, path string, body io.Reader) (*http.Response, error) {
|
||||||
req, err := http.NewRequestWithContext(ctx, method, a.cfg.Host+path, body)
|
req, err := http.NewRequestWithContext(ctx, method, a.host()+path, body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -26,6 +26,10 @@ func (a *Agent) runTask(ctx context.Context, taskID string) {
|
|||||||
res = a.doFSPut(ctx, env)
|
res = a.doFSPut(ctx, env)
|
||||||
case proto.TaskFSSend:
|
case proto.TaskFSSend:
|
||||||
res = a.doFSSend(ctx, env)
|
res = a.doFSSend(ctx, env)
|
||||||
|
case proto.TaskFSShipOut:
|
||||||
|
res = a.doShipOut(ctx, env)
|
||||||
|
case proto.TaskFSShipIn:
|
||||||
|
res = a.doShipIn(ctx, env)
|
||||||
default:
|
default:
|
||||||
res = a.execTask(env)
|
res = a.execTask(env)
|
||||||
}
|
}
|
||||||
@@ -34,7 +38,7 @@ func (a *Agent) runTask(ctx context.Context, taskID string) {
|
|||||||
|
|
||||||
func (a *Agent) fetchTask(ctx context.Context, taskID string) (proto.TaskEnvelope, error) {
|
func (a *Agent) fetchTask(ctx context.Context, taskID string) (proto.TaskEnvelope, error) {
|
||||||
var env proto.TaskEnvelope
|
var env proto.TaskEnvelope
|
||||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, a.cfg.Host+"/api/agent/task/"+taskID, nil)
|
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, a.host()+"/api/agent/task/"+taskID, nil)
|
||||||
a.authHeaders(req)
|
a.authHeaders(req)
|
||||||
resp, err := a.http.Do(req)
|
resp, err := a.http.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -50,7 +54,7 @@ func (a *Agent) fetchTask(ctx context.Context, taskID string) (proto.TaskEnvelop
|
|||||||
|
|
||||||
func (a *Agent) postResult(ctx context.Context, taskID string, res proto.TaskResult) {
|
func (a *Agent) postResult(ctx context.Context, taskID string, res proto.TaskResult) {
|
||||||
body, _ := json.Marshal(res)
|
body, _ := json.Marshal(res)
|
||||||
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, a.cfg.Host+"/api/agent/task/"+taskID+"/result", bytes.NewReader(body))
|
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, a.host()+"/api/agent/task/"+taskID+"/result", bytes.NewReader(body))
|
||||||
a.authHeaders(req)
|
a.authHeaders(req)
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
resp, err := a.http.Do(req)
|
resp, err := a.http.Do(req)
|
||||||
|
|||||||
@@ -4,14 +4,38 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/creack/pty"
|
"github.com/creack/pty"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PTY is a pseudo-terminal running an interactive login shell.
|
// PTY is a pseudo-terminal running an interactive login shell.
|
||||||
type PTY struct {
|
type PTY struct {
|
||||||
cmd *exec.Cmd
|
cmd *exec.Cmd
|
||||||
f *os.File
|
f *os.File
|
||||||
|
exited chan struct{} // closed once the shell has exited
|
||||||
|
once sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
// hangupGrace is how long a shell gets after the hangup to save its history
|
||||||
|
// and exit before it is killed.
|
||||||
|
const hangupGrace = 3 * time.Second
|
||||||
|
|
||||||
|
// openPTYs are the terminals running now, to close on shutdown.
|
||||||
|
var openPTYs sync.Map // *PTY -> struct{}
|
||||||
|
|
||||||
|
// CloseTerminals hangs up every open terminal and waits for them, so the
|
||||||
|
// shells save their history before the agent exits.
|
||||||
|
func CloseTerminals() {
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
openPTYs.Range(func(k, _ any) bool {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() { defer wg.Done(); k.(*PTY).Close() }()
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
wg.Wait()
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartPTY launches a login shell attached to a new PTY. With user empty (or
|
// StartPTY launches a login shell attached to a new PTY. With user empty (or
|
||||||
@@ -56,7 +80,10 @@ func StartPTY(user string) (*PTY, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &PTY{cmd: cmd, f: f}, nil
|
p := &PTY{cmd: cmd, f: f, exited: make(chan struct{})}
|
||||||
|
go func() { _ = cmd.Wait(); close(p.exited) }()
|
||||||
|
openPTYs.Store(p, struct{}{})
|
||||||
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func isDir(p string) bool {
|
func isDir(p string) bool {
|
||||||
@@ -78,12 +105,24 @@ func (p *PTY) Resize(cols, rows uint16) {
|
|||||||
_ = pty.Setsize(p.f, &pty.Winsize{Rows: rows, Cols: cols})
|
_ = pty.Setsize(p.f, &pty.Winsize{Rows: rows, Cols: cols})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close terminates the shell and releases the PTY.
|
// Close hangs the terminal up as a closed window would: the shell gets
|
||||||
|
// SIGHUP, saves its history and exits. One that does not within hangupGrace
|
||||||
|
// is killed. Safe to call more than once.
|
||||||
func (p *PTY) Close() error {
|
func (p *PTY) Close() error {
|
||||||
err := p.f.Close()
|
var err error
|
||||||
if p.cmd.Process != nil {
|
p.once.Do(func() {
|
||||||
_ = p.cmd.Process.Kill()
|
// Closing the master hangs up the session; the signal to the
|
||||||
}
|
// shell's process group makes sure it hears, through su as well.
|
||||||
_ = p.cmd.Wait()
|
err = p.f.Close()
|
||||||
|
pid := p.cmd.Process.Pid
|
||||||
|
_ = syscall.Kill(-pid, syscall.SIGHUP)
|
||||||
|
select {
|
||||||
|
case <-p.exited:
|
||||||
|
case <-time.After(hangupGrace):
|
||||||
|
_ = syscall.Kill(-pid, syscall.SIGKILL)
|
||||||
|
<-p.exited
|
||||||
|
}
|
||||||
|
openPTYs.Delete(p)
|
||||||
|
})
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -26,9 +26,7 @@ func (a *Agent) runTunnel(ctx context.Context, addr string) {
|
|||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if backoff < 15*time.Second {
|
backoff = min(backoff*2, retryCap)
|
||||||
backoff *= 2
|
|
||||||
}
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
backoff = time.Second
|
backoff = time.Second
|
||||||
@@ -96,8 +94,14 @@ func (t *tunClient) write(f tunnel.Frame) error {
|
|||||||
return tunnel.WriteFrame(t.conn, f)
|
return tunnel.WriteFrame(t.conn, f)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tunnelSilence is how long the tunnel may say nothing before it is taken
|
||||||
|
// for dead: the server pings every 20 s, so this is three missed pings - a
|
||||||
|
// connection a NAT forgot while the machine slept, say.
|
||||||
|
const tunnelSilence = 65 * time.Second
|
||||||
|
|
||||||
func (t *tunClient) readLoop() error {
|
func (t *tunClient) readLoop() error {
|
||||||
for {
|
for {
|
||||||
|
_ = t.conn.SetReadDeadline(time.Now().Add(tunnelSilence))
|
||||||
f, err := tunnel.ReadFrame(t.conn)
|
f, err := tunnel.ReadFrame(t.conn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.shutdown()
|
t.shutdown()
|
||||||
@@ -207,9 +211,7 @@ func (a *Agent) runUDP(ctx context.Context, addr string) {
|
|||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if backoff < 15*time.Second {
|
backoff = min(backoff*2, retryCap)
|
||||||
backoff *= 2
|
|
||||||
}
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
backoff = time.Second
|
backoff = time.Second
|
||||||
|
|||||||
+120
-27
@@ -3,31 +3,40 @@ package agent
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||||
)
|
)
|
||||||
|
|
||||||
// doUpdate downloads the latest agent binary for this platform, atomically
|
// doUpdate downloads the latest agent binary for this platform, replaces the
|
||||||
// replaces the running executable, and schedules a service restart. It never
|
// running executable and restarts the service. It runs as root when it can:
|
||||||
// uninstalls - only updates.
|
// the agent's own user if that is root, otherwise sudo when it needs no
|
||||||
|
// password. It never uninstalls - only updates.
|
||||||
func (a *Agent) doUpdate() proto.TaskResult {
|
func (a *Agent) doUpdate() proto.TaskResult {
|
||||||
exe, err := os.Executable()
|
exe, err := os.Executable()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return proto.TaskResult{Error: "не удалось определить путь бинарника: " + err.Error()}
|
return proto.TaskResult{Error: "не удалось определить путь бинарника: " + err.Error()}
|
||||||
}
|
}
|
||||||
exe, _ = filepath.EvalSymlinks(exe)
|
exe, _ = filepath.EvalSymlinks(exe)
|
||||||
|
sudo := ""
|
||||||
|
if os.Geteuid() != 0 {
|
||||||
|
sudo = sudoPath()
|
||||||
|
}
|
||||||
|
|
||||||
url := fmt.Sprintf("%s/download/agent-%s-%s", a.cfg.Host, runtime.GOOS, runtime.GOARCH)
|
url := fmt.Sprintf("%s/download/agent-%s-%s", a.host(), runtime.GOOS, runtime.GOARCH)
|
||||||
req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
|
req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
|
||||||
resp, err := a.http.Do(req)
|
resp, err := a.stream.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return proto.TaskResult{Error: "скачивание: " + err.Error()}
|
return proto.TaskResult{Error: "скачивание: " + err.Error()}
|
||||||
}
|
}
|
||||||
@@ -36,41 +45,125 @@ func (a *Agent) doUpdate() proto.TaskResult {
|
|||||||
return proto.TaskResult{Error: "скачивание: сервер вернул " + resp.Status}
|
return proto.TaskResult{Error: "скачивание: сервер вернул " + resp.Status}
|
||||||
}
|
}
|
||||||
|
|
||||||
tmp := exe + ".new"
|
// The new binary lands next to the old one, or, when root's hand is
|
||||||
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755)
|
// needed to put it there, in a temporary folder first.
|
||||||
if err != nil {
|
dir := filepath.Dir(exe)
|
||||||
return proto.TaskResult{Error: "создание файла: " + err.Error()}
|
if sudo != "" {
|
||||||
|
dir = ""
|
||||||
}
|
}
|
||||||
if _, err := io.Copy(f, resp.Body); err != nil {
|
f, err := os.CreateTemp(dir, ".lpagent-new-*")
|
||||||
f.Close()
|
if err != nil {
|
||||||
os.Remove(tmp)
|
return proto.TaskResult{Error: "создание файла: " + err.Error() + userNote()}
|
||||||
|
}
|
||||||
|
tmp := f.Name()
|
||||||
|
defer os.Remove(tmp)
|
||||||
|
n, err := io.Copy(f, io.LimitReader(resp.Body, 256<<20))
|
||||||
|
if cerr := f.Close(); err == nil {
|
||||||
|
err = cerr
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
return proto.TaskResult{Error: "запись: " + err.Error()}
|
return proto.TaskResult{Error: "запись: " + err.Error()}
|
||||||
}
|
}
|
||||||
f.Close()
|
if err := checkBinary(tmp, n); err != nil {
|
||||||
|
return proto.TaskResult{Error: err.Error()}
|
||||||
|
}
|
||||||
|
if err := os.Chmod(tmp, 0o755); err != nil {
|
||||||
|
return proto.TaskResult{Error: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
// Atomic replace: renaming over a running executable is allowed on Linux;
|
how := "от " + currentUser()
|
||||||
// the live process keeps the old inode until it restarts.
|
if sudo != "" {
|
||||||
if err := os.Rename(tmp, exe); err != nil {
|
// install copies it in place under a new inode, as a rename would.
|
||||||
os.Remove(tmp)
|
if out, err := exec.Command(sudo, "-n", "install", "-m", "0755", tmp, exe).CombinedOutput(); err != nil {
|
||||||
return proto.TaskResult{Error: "замена бинарника: " + err.Error()}
|
return proto.TaskResult{Error: "замена бинарника через sudo: " + strings.TrimSpace(string(out)+" "+err.Error())}
|
||||||
|
}
|
||||||
|
how = "через sudo"
|
||||||
|
} else if err := os.Rename(tmp, exe); err != nil {
|
||||||
|
// Renaming over a running executable is allowed; the live process
|
||||||
|
// keeps the old inode until it restarts.
|
||||||
|
return proto.TaskResult{Error: "замена бинарника: " + err.Error() + userNote()}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restart shortly, after the result has been reported.
|
// Restart shortly, after the result has been reported.
|
||||||
go func() {
|
go func() {
|
||||||
time.Sleep(1500 * time.Millisecond)
|
time.Sleep(1500 * time.Millisecond)
|
||||||
restartSelf()
|
restartSelf(exe, sudo)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
data, _ := json.Marshal(map[string]string{"status": "обновлено, перезапуск"})
|
data, _ := json.Marshal(map[string]string{"status": "обновлено " + how + ", перезапуск"})
|
||||||
return proto.TaskResult{OK: true, Data: data}
|
return proto.TaskResult{OK: true, Data: data}
|
||||||
}
|
}
|
||||||
|
|
||||||
// restartSelf restarts the agent so the new binary takes effect.
|
// sudoPath is sudo when it lets this user run commands without a password.
|
||||||
func restartSelf() {
|
func sudoPath() string {
|
||||||
if path, err := exec.LookPath("systemctl"); err == nil {
|
p, err := exec.LookPath("sudo")
|
||||||
_ = exec.Command(path, "restart", "lpagent").Start()
|
if err != nil {
|
||||||
return
|
return ""
|
||||||
|
}
|
||||||
|
if exec.Command(p, "-n", "true").Run() != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func userNote() string {
|
||||||
|
if os.Geteuid() == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return " (агент запущен от " + currentUser() + ", без root и sudo без пароля - обновите установщиком)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkBinary makes sure what came is a program for this system, not an
|
||||||
|
// error page: a binary of a sensible size with the right header.
|
||||||
|
func checkBinary(path string, size int64) error {
|
||||||
|
if size < 1<<20 {
|
||||||
|
return fmt.Errorf("скачанный файл слишком мал (%d байт) - не похоже на агента", size)
|
||||||
|
}
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
var magic [4]byte
|
||||||
|
if _, err := io.ReadFull(f, magic[:]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ok := false
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "linux":
|
||||||
|
ok = string(magic[:]) == "\x7fELF"
|
||||||
|
case "darwin":
|
||||||
|
ok = magic == [4]byte{0xcf, 0xfa, 0xed, 0xfe} || magic == [4]byte{0xca, 0xfe, 0xba, 0xbe}
|
||||||
|
default:
|
||||||
|
ok = true
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return errors.New("скачанный файл - не программа для " + runtime.GOOS)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// restartSelf starts the new binary. Under systemd the service is restarted
|
||||||
|
// (queued, so it goes on after this process is stopped); otherwise the
|
||||||
|
// process replaces itself with the new binary.
|
||||||
|
func restartSelf(exe, sudo string) {
|
||||||
|
if os.Getenv("INVOCATION_ID") != "" {
|
||||||
|
if path, err := exec.LookPath("systemctl"); err == nil {
|
||||||
|
args := []string{"--no-block", "restart", "lpagent"}
|
||||||
|
cmd := exec.Command(path, args...)
|
||||||
|
if sudo != "" {
|
||||||
|
cmd = exec.Command(sudo, append([]string{"-n", path}, args...)...)
|
||||||
|
}
|
||||||
|
if cmd.Run() == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// systemd brings a service that stopped back up (Restart=always).
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
CloseTerminals()
|
||||||
|
if err := syscall.Exec(exe, os.Args, os.Environ()); err != nil {
|
||||||
|
log.Printf("update: restart: %v", err)
|
||||||
|
os.Exit(0)
|
||||||
}
|
}
|
||||||
// No systemd (e.g. launchd/manual run): exit so the supervisor restarts us.
|
|
||||||
os.Exit(0)
|
|
||||||
}
|
}
|
||||||
@@ -39,6 +39,8 @@ func dispatchTask(env proto.TaskEnvelope) (any, error) {
|
|||||||
var a proto.SysUserAddArgs
|
var a proto.SysUserAddArgs
|
||||||
_ = json.Unmarshal(env.Args, &a)
|
_ = json.Unmarshal(env.Args, &a)
|
||||||
return nil, addUser(a)
|
return nil, addUser(a)
|
||||||
|
case proto.TaskDBTables, proto.TaskDBRows, proto.TaskDBSet, proto.TaskDBAdd, proto.TaskDBDel, proto.TaskDBSQL:
|
||||||
|
return dbTask(env)
|
||||||
}
|
}
|
||||||
return dispatchFS(env)
|
return dispatchFS(env)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+149
-5
@@ -18,8 +18,23 @@ const (
|
|||||||
CtrlOpenForward = "open_forward"
|
CtrlOpenForward = "open_forward"
|
||||||
// CtrlTask tells the agent to fetch and run the one-shot task TaskID.
|
// CtrlTask tells the agent to fetch and run the one-shot task TaskID.
|
||||||
CtrlTask = "task"
|
CtrlTask = "task"
|
||||||
|
// CtrlOpenSSH tells the agent to open an SSH connection to SSH and bridge
|
||||||
|
// its shell to the session id, as a terminal.
|
||||||
|
CtrlOpenSSH = "open_ssh"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// SSHTarget is where an SSH terminal connects and how it signs in: a
|
||||||
|
// password, a private key (PEM or OpenSSH, with its passphrase if it has
|
||||||
|
// one), or, with neither, the agent user's own keys in ~/.ssh.
|
||||||
|
type SSHTarget struct {
|
||||||
|
Host string `json:"host"`
|
||||||
|
Port int `json:"port,omitempty"`
|
||||||
|
User string `json:"user"`
|
||||||
|
Password string `json:"password,omitempty"`
|
||||||
|
Key string `json:"key,omitempty"`
|
||||||
|
Passphrase string `json:"passphrase,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// EnrollRequest is sent once by a freshly installed agent.
|
// EnrollRequest is sent once by a freshly installed agent.
|
||||||
type EnrollRequest struct {
|
type EnrollRequest struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -38,11 +53,49 @@ type EnrollResponse struct {
|
|||||||
|
|
||||||
// ControlMessage is the body returned by the control long-poll.
|
// ControlMessage is the body returned by the control long-poll.
|
||||||
type ControlMessage struct {
|
type ControlMessage struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
SessionID string `json:"session_id,omitempty"`
|
SessionID string `json:"session_id,omitempty"`
|
||||||
Port int `json:"port,omitempty"` // CtrlOpenForward: client-side port to dial
|
Port int `json:"port,omitempty"` // CtrlOpenForward: client-side port to dial
|
||||||
TaskID string `json:"task_id,omitempty"` // CtrlTask
|
TaskID string `json:"task_id,omitempty"` // CtrlTask
|
||||||
User string `json:"user,omitempty"` // CtrlOpenTerminal: OS user for the shell ("" = the agent's own)
|
User string `json:"user,omitempty"` // CtrlOpenTerminal: OS user for the shell ("" = the agent's own)
|
||||||
|
SSH *SSHTarget `json:"ssh,omitempty"` // CtrlOpenSSH
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transports an agent can reach the server by.
|
||||||
|
const (
|
||||||
|
TransportAuto = "auto" // WebSocket, long polling while it cannot connect
|
||||||
|
TransportWS = "ws" // WebSocket only
|
||||||
|
TransportLP = "lp" // long polling only
|
||||||
|
)
|
||||||
|
|
||||||
|
// TransportHeader carries the agent's transport setting on poll and config
|
||||||
|
// answers.
|
||||||
|
const TransportHeader = "X-Transport"
|
||||||
|
|
||||||
|
// FallbackHeader carries the server's direct address (one not behind a
|
||||||
|
// proxy like Cloudflare) on poll and config answers, for the agent to turn
|
||||||
|
// to when the main one is out of reach.
|
||||||
|
const FallbackHeader = "X-Fallback-Host"
|
||||||
|
|
||||||
|
// MainHeader carries the server's main address beside FallbackHeader, for an
|
||||||
|
// agent set to the direct one to fall back to the main one.
|
||||||
|
const MainHeader = "X-Main-Host"
|
||||||
|
|
||||||
|
// WSMsg is one message on an agent's WebSocket, either way. Server to agent:
|
||||||
|
// "ctrl" (Ctrl, as a control poll would answer), "cfg" (Tunnel and
|
||||||
|
// Transport, also a keep-alive), "in" (Frames for session SID), "gone"
|
||||||
|
// (session SID is over). Agent to server: "sub" (send the input of session
|
||||||
|
// SID here), "out" (Out of session SID).
|
||||||
|
type WSMsg struct {
|
||||||
|
T string `json:"t"`
|
||||||
|
Ctrl *ControlMessage `json:"ctrl,omitempty"`
|
||||||
|
SID string `json:"sid,omitempty"`
|
||||||
|
Frames []InFrame `json:"frames,omitempty"`
|
||||||
|
Out *OutMessage `json:"out,omitempty"`
|
||||||
|
Tunnel string `json:"tunnel,omitempty"`
|
||||||
|
Transport string `json:"transport,omitempty"`
|
||||||
|
Fallback string `json:"fallback,omitempty"`
|
||||||
|
Main string `json:"main,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Terminal / stream frame types on the session "in" channel (server -> agent).
|
// Terminal / stream frame types on the session "in" channel (server -> agent).
|
||||||
@@ -70,8 +123,20 @@ type InBatch struct {
|
|||||||
type OutMessage struct {
|
type OutMessage struct {
|
||||||
Data string `json:"data,omitempty"` // base64 bytes
|
Data string `json:"data,omitempty"` // base64 bytes
|
||||||
Exit bool `json:"exit,omitempty"`
|
Exit bool `json:"exit,omitempty"`
|
||||||
|
// Status tells the panel how an SSH terminal's connection goes: one of
|
||||||
|
// the SSHStatus values, with Note to show.
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
Note string `json:"note,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The steps of an SSH terminal's connection, as OutMessage.Status.
|
||||||
|
const (
|
||||||
|
SSHDial = "dial" // connecting to the host
|
||||||
|
SSHAuth = "auth" // the host answered, signing in
|
||||||
|
SSHOK = "ok" // signed in, the shell runs
|
||||||
|
SSHErr = "err" // it failed; Note says why
|
||||||
|
)
|
||||||
|
|
||||||
// --- one-shot tasks (file manager, native in the agent) ---
|
// --- one-shot tasks (file manager, native in the agent) ---
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -85,6 +150,9 @@ const (
|
|||||||
TaskFSZip = "fs.zip" // zip selected paths (native archive/zip)
|
TaskFSZip = "fs.zip" // zip selected paths (native archive/zip)
|
||||||
TaskFSMove = "fs.move" // move/rename
|
TaskFSMove = "fs.move" // move/rename
|
||||||
TaskFSDownload = "fs.download" // read a whole file (larger cap) for browser download
|
TaskFSDownload = "fs.download" // read a whole file (larger cap) for browser download
|
||||||
|
TaskFSStat = "fs.stat" // a file's properties: type, size, mode, owner
|
||||||
|
TaskFSChmod = "fs.chmod" // change mode and owner, of a folder's contents too
|
||||||
|
TaskFSCopy = "fs.copy" // copy or move, with a rule for what is already there
|
||||||
TaskFSPut = "fs.put" // write a file the browser sends in pieces
|
TaskFSPut = "fs.put" // write a file the browser sends in pieces
|
||||||
TaskFSSend = "fs.send" // send a file to the browser in pieces
|
TaskFSSend = "fs.send" // send a file to the browser in pieces
|
||||||
|
|
||||||
@@ -93,6 +161,14 @@ const (
|
|||||||
TaskSysUserAdd = "sys.useradd" // create a new OS user
|
TaskSysUserAdd = "sys.useradd" // create a new OS user
|
||||||
TaskSysStat = "sys.stat" // CPU load, RAM and disk usage right now
|
TaskSysStat = "sys.stat" // CPU load, RAM and disk usage right now
|
||||||
TaskSysSpeed = "sys.speed" // measure the link to the server (ping, down, up)
|
TaskSysSpeed = "sys.speed" // measure the link to the server (ping, down, up)
|
||||||
|
TaskDBTables = "db.tables" // an SQLite database's tables and views
|
||||||
|
TaskDBRows = "db.rows" // a page of a table's rows, with each row's key
|
||||||
|
TaskDBSet = "db.set" // set one value of one row
|
||||||
|
TaskDBAdd = "db.add" // add a row
|
||||||
|
TaskDBDel = "db.del" // delete rows by their keys
|
||||||
|
TaskDBSQL = "db.sql" // run a statement typed in the panel
|
||||||
|
TaskFSShipOut = "fs.ship.out" // pack a file or folder and send it to another machine
|
||||||
|
TaskFSShipIn = "fs.ship.in" // unpack what another machine sends
|
||||||
)
|
)
|
||||||
|
|
||||||
// FSReadCap bounds how many bytes TaskFSRead returns (5 MiB). The panel lets a
|
// FSReadCap bounds how many bytes TaskFSRead returns (5 MiB). The panel lets a
|
||||||
@@ -146,6 +222,47 @@ type FSMoveArgs struct {
|
|||||||
Dst string `json:"dst"`
|
Dst string `json:"dst"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FSStatResult is returned for TaskFSStat. Mode holds the permission bits
|
||||||
|
// with setuid, setgid and sticky (07777). Users and Groups are the names on
|
||||||
|
// the machine, offered when choosing an owner.
|
||||||
|
type FSStatResult struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
IsDir bool `json:"is_dir"`
|
||||||
|
Link string `json:"link,omitempty"` // where a symlink points
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
MTime int64 `json:"mtime"`
|
||||||
|
Mode uint32 `json:"mode"`
|
||||||
|
UID int `json:"uid"`
|
||||||
|
GID int `json:"gid"`
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
Group string `json:"group"`
|
||||||
|
Users []string `json:"users"`
|
||||||
|
Groups []string `json:"groups"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FSChmodArgs are the arguments of TaskFSChmod. Mode nil, Owner or Group
|
||||||
|
// empty leave that as it is. Recursive applies to everything inside a folder
|
||||||
|
// as well, Only narrowing it to "files" or "dirs".
|
||||||
|
type FSChmodArgs struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Mode *uint32 `json:"mode,omitempty"`
|
||||||
|
Owner string `json:"owner,omitempty"`
|
||||||
|
Group string `json:"group,omitempty"`
|
||||||
|
Recursive bool `json:"recursive,omitempty"`
|
||||||
|
Only string `json:"only,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FSCopyArgs are the arguments of TaskFSCopy: Src goes to Dst (the full new
|
||||||
|
// path), copied or, with Move, moved. Conflict says what to do when Dst is
|
||||||
|
// already there: "error" (the default) stops, "replace" puts Src in its place,
|
||||||
|
// "skip" keeps it. Folders on both sides are merged under replace and skip.
|
||||||
|
type FSCopyArgs struct {
|
||||||
|
Src string `json:"src"`
|
||||||
|
Dst string `json:"dst"`
|
||||||
|
Move bool `json:"move,omitempty"`
|
||||||
|
Conflict string `json:"conflict,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// FSEntry is one directory entry.
|
// FSEntry is one directory entry.
|
||||||
type FSEntry struct {
|
type FSEntry struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -215,3 +332,30 @@ type SysSpeedResult struct {
|
|||||||
// counted on its own side by then.
|
// counted on its own side by then.
|
||||||
Partial bool `json:"partial,omitempty"`
|
Partial bool `json:"partial,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FSShipArgs are the arguments of TaskFSShipOut and TaskFSShipIn: Src (on the
|
||||||
|
// sending machine) lands in Dir as Name (on the receiving one), following
|
||||||
|
// Conflict as FSCopyArgs does. The sender packs it as a tar stream and either
|
||||||
|
// posts it in pieces of Chunk bytes to Relay on the server, for the receiver
|
||||||
|
// to take, or with SSH set writes it to tar on that host itself, and with
|
||||||
|
// Move then deletes Src. Both report to Job how far they are.
|
||||||
|
type FSShipArgs struct {
|
||||||
|
Src string `json:"src,omitempty"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Dir string `json:"dir"`
|
||||||
|
Conflict string `json:"conflict,omitempty"`
|
||||||
|
Move bool `json:"move,omitempty"`
|
||||||
|
Relay string `json:"relay,omitempty"`
|
||||||
|
Chunk int `json:"chunk,omitempty"`
|
||||||
|
Job string `json:"job,omitempty"`
|
||||||
|
SSH *SSHTarget `json:"ssh,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShipProgress is what a sending machine reports: bytes of files sent out of
|
||||||
|
// Total, and files done.
|
||||||
|
type ShipProgress struct {
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
Done int64 `json:"done"`
|
||||||
|
Files int `json:"files"`
|
||||||
|
Note string `json:"note,omitempty"` // what is going on: connecting, signing in
|
||||||
|
}
|
||||||
+51
-11
@@ -3,6 +3,7 @@ package server
|
|||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
lpra "github.com/x-grk/longpolling-remote-access"
|
lpra "github.com/x-grk/longpolling-remote-access"
|
||||||
@@ -14,9 +15,11 @@ const (
|
|||||||
cfgEnrollSecret = "enroll_secret"
|
cfgEnrollSecret = "enroll_secret"
|
||||||
cfgSetupDone = "setup_done"
|
cfgSetupDone = "setup_done"
|
||||||
cfgInstallCode = "install_code"
|
cfgInstallCode = "install_code"
|
||||||
|
cfgInstallLen = "install_code_len"
|
||||||
cfgUfwEnabled = "ufw_enabled"
|
cfgUfwEnabled = "ufw_enabled"
|
||||||
cfgTunnelHost = "tunnel_host"
|
cfgTunnelHost = "tunnel_host"
|
||||||
cfgTunnelPort = "tunnel_port"
|
cfgTunnelPort = "tunnel_port"
|
||||||
|
cfgDirectAddr = "direct_address"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DefaultHostAddress is the public URL agents dial by default. It is baked into
|
// DefaultHostAddress is the public URL agents dial by default. It is baked into
|
||||||
@@ -52,7 +55,7 @@ func (db *DB) ensureDefaults() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if code == "" {
|
if code == "" {
|
||||||
if err := db.SetConfig(cfgInstallCode, shortCode()); err != nil {
|
if err := db.SetConfig(cfgInstallCode, shortCode(db.InstallCodeLen())); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,6 +70,23 @@ func (db *DB) HostAddress() string {
|
|||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DirectAddress is the server's direct URL, one that is not behind a proxy
|
||||||
|
// like Cloudflare: the agents' fallback, and a way to install when the main
|
||||||
|
// address is out of reach. Empty when not set.
|
||||||
|
func (db *DB) DirectAddress() string {
|
||||||
|
v, _ := db.GetConfig(cfgDirectAddr)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// directHost is the direct address's host name, without scheme and port.
|
||||||
|
func directHost(addr string) string {
|
||||||
|
u, err := url.Parse(addr)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return u.Hostname()
|
||||||
|
}
|
||||||
|
|
||||||
func (db *DB) EnrollSecret() string {
|
func (db *DB) EnrollSecret() string {
|
||||||
v, _ := db.GetConfig(cfgEnrollSecret)
|
v, _ := db.GetConfig(cfgEnrollSecret)
|
||||||
return v
|
return v
|
||||||
@@ -125,16 +145,36 @@ func randToken() string {
|
|||||||
return hex.EncodeToString(b)
|
return hex.EncodeToString(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
// shortCode returns a short, URL-safe, typo-resistant code for install links.
|
// Install code length: 3 by default, up to 24.
|
||||||
// It avoids ambiguous characters (0/O, 1/l/I) so it is easy to read and type.
|
const (
|
||||||
func shortCode() string {
|
installCodeMin = 3
|
||||||
const alphabet = "abcdefghjkmnpqrstuvwxyz23456789"
|
installCodeMax = 24
|
||||||
b := make([]byte, 7)
|
installCodeDef = 3
|
||||||
if _, err := rand.Read(b); err != nil {
|
)
|
||||||
panic("crypto/rand failed: " + err.Error())
|
|
||||||
|
// InstallCodeLen is how many characters a new install code has.
|
||||||
|
func (db *DB) InstallCodeLen() int {
|
||||||
|
v, _ := db.GetConfig(cfgInstallLen)
|
||||||
|
if n, err := strconv.Atoi(v); err == nil && n >= installCodeMin && n <= installCodeMax {
|
||||||
|
return n
|
||||||
}
|
}
|
||||||
for i := range b {
|
return installCodeDef
|
||||||
b[i] = alphabet[int(b[i])%len(alphabet)]
|
}
|
||||||
|
|
||||||
|
// shortCode returns a random code of n English letters and digits for the
|
||||||
|
// /i/<code> install link. Every character is equally likely.
|
||||||
|
func shortCode(n int) string {
|
||||||
|
const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||||
|
out := make([]byte, 0, n)
|
||||||
|
b := make([]byte, 1)
|
||||||
|
for len(out) < n {
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
panic("crypto/rand failed: " + err.Error())
|
||||||
|
}
|
||||||
|
// 248 = 4 * 62: drop the rest, so no character comes up more often.
|
||||||
|
if b[0] < 248 {
|
||||||
|
out = append(out, alphabet[int(b[0])%len(alphabet)])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return string(b)
|
return string(out)
|
||||||
}
|
}
|
||||||
+272
-19
@@ -85,6 +85,16 @@ CREATE TABLE IF NOT EXISTS ufw_rules (
|
|||||||
created_at INTEGER NOT NULL,
|
created_at INTEGER NOT NULL,
|
||||||
UNIQUE(port, proto)
|
UNIQUE(port, proto)
|
||||||
);
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS admin_keys (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
admin_id INTEGER NOT NULL REFERENCES admins(id) ON DELETE CASCADE,
|
||||||
|
cred_id BLOB UNIQUE NOT NULL,
|
||||||
|
public_key BLOB NOT NULL,
|
||||||
|
sign_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
last_used INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
CREATE TABLE IF NOT EXISTS forward_rules (
|
CREATE TABLE IF NOT EXISTS forward_rules (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
agent_id TEXT NOT NULL,
|
agent_id TEXT NOT NULL,
|
||||||
@@ -107,6 +117,15 @@ CREATE TABLE IF NOT EXISTS forward_rules (
|
|||||||
`ALTER TABLE agents ADD COLUMN login_user TEXT NOT NULL DEFAULT ''`,
|
`ALTER TABLE agents ADD COLUMN login_user TEXT NOT NULL DEFAULT ''`,
|
||||||
`ALTER TABLE forward_rules ADD COLUMN no_ufw INTEGER NOT NULL DEFAULT 0`,
|
`ALTER TABLE forward_rules ADD COLUMN no_ufw INTEGER NOT NULL DEFAULT 0`,
|
||||||
`ALTER TABLE forward_rules ADD COLUMN label TEXT NOT NULL DEFAULT ''`,
|
`ALTER TABLE forward_rules ADD COLUMN label TEXT NOT NULL DEFAULT ''`,
|
||||||
|
`ALTER TABLE admins ADD COLUMN password_off INTEGER NOT NULL DEFAULT 0`,
|
||||||
|
`ALTER TABLE agents ADD COLUMN transport TEXT NOT NULL DEFAULT 'auto'`,
|
||||||
|
`ALTER TABLE admins ADD COLUMN pin_hash TEXT NOT NULL DEFAULT ''`,
|
||||||
|
`ALTER TABLE admins ADD COLUMN role TEXT NOT NULL DEFAULT 'admin'`,
|
||||||
|
`ALTER TABLE admins ADD COLUMN pw_locked INTEGER NOT NULL DEFAULT 0`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS admin_agents (
|
||||||
|
admin_id INTEGER NOT NULL REFERENCES admins(id) ON DELETE CASCADE,
|
||||||
|
agent_id TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (admin_id, agent_id))`,
|
||||||
} {
|
} {
|
||||||
if _, err := db.sql.Exec(alter); err != nil && !strings.Contains(err.Error(), "duplicate column") {
|
if _, err := db.sql.Exec(alter); err != nil && !strings.Contains(err.Error(), "duplicate column") {
|
||||||
return err
|
return err
|
||||||
@@ -142,24 +161,56 @@ func (db *DB) CountAdmins() (int, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) CreateAdmin(username, passHash string) error {
|
func (db *DB) CreateAdmin(username, passHash string) error {
|
||||||
_, err := db.sql.Exec(
|
_, err := db.CreateUser(username, passHash, RoleAdmin)
|
||||||
`INSERT INTO admins(username, pass_hash, created_at) VALUES(?, ?, ?)`,
|
|
||||||
username, passHash, time.Now().Unix())
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Admin is one row of the admins table.
|
// CreateUser adds a panel user with the role given and returns its id.
|
||||||
|
func (db *DB) CreateUser(username, passHash, role string) (int64, error) {
|
||||||
|
res, err := db.sql.Exec(
|
||||||
|
`INSERT INTO admins(username, pass_hash, created_at, role) VALUES(?, ?, ?, ?)`,
|
||||||
|
username, passHash, time.Now().Unix(), role)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return res.LastInsertId()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Roles of panel users: an admin sees and runs everything; a user only the
|
||||||
|
// machines given to them, and none of the settings.
|
||||||
|
const (
|
||||||
|
RoleAdmin = "admin"
|
||||||
|
RoleUser = "user"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Admin is one row of the admins table: a panel user, an admin or not.
|
||||||
type Admin struct {
|
type Admin struct {
|
||||||
ID int64
|
ID int64
|
||||||
Username string
|
Username string
|
||||||
PassHash string
|
PassHash string
|
||||||
|
Role string
|
||||||
|
// PwLocked keeps the user from turning the password sign-in on or off
|
||||||
|
// themselves: an admin set it.
|
||||||
|
PwLocked bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsAdmin reports whether the user has the run of the whole panel.
|
||||||
|
func (a *Admin) IsAdmin() bool { return a != nil && a.Role != RoleUser }
|
||||||
|
|
||||||
|
const adminCols = `id, username, pass_hash, role, pw_locked`
|
||||||
|
|
||||||
|
func scanAdmin(row interface{ Scan(...any) error }) (*Admin, error) {
|
||||||
|
a := &Admin{}
|
||||||
|
var locked int
|
||||||
|
if err := row.Scan(&a.ID, &a.Username, &a.PassHash, &a.Role, &locked); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
a.PwLocked = locked == 1
|
||||||
|
return a, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) AdminByUsername(username string) (*Admin, error) {
|
func (db *DB) AdminByUsername(username string) (*Admin, error) {
|
||||||
a := &Admin{}
|
a, err := scanAdmin(db.sql.QueryRow(`SELECT `+adminCols+` FROM admins WHERE username = ?`, username))
|
||||||
err := db.sql.QueryRow(
|
|
||||||
`SELECT id, username, pass_hash FROM admins WHERE username = ?`, username).
|
|
||||||
Scan(&a.ID, &a.Username, &a.PassHash)
|
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -167,27 +218,215 @@ func (db *DB) AdminByUsername(username string) (*Admin, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) ListAdmins() ([]Admin, error) {
|
func (db *DB) ListAdmins() ([]Admin, error) {
|
||||||
rows, err := db.sql.Query(`SELECT id, username FROM admins ORDER BY username`)
|
rows, err := db.sql.Query(`SELECT ` + adminCols + ` FROM admins ORDER BY role, username`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
var out []Admin
|
var out []Admin
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var a Admin
|
a, err := scanAdmin(rows)
|
||||||
if err := rows.Scan(&a.ID, &a.Username); err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
out = append(out, a)
|
out = append(out, *a)
|
||||||
}
|
}
|
||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CountRole is how many users have the role.
|
||||||
|
func (db *DB) CountRole(role string) int {
|
||||||
|
var n int
|
||||||
|
_ = db.sql.QueryRow(`SELECT COUNT(*) FROM admins WHERE role = ?`, role).Scan(&n)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) SetUserRole(id int64, role string) error {
|
||||||
|
_, err := db.sql.Exec(`UPDATE admins SET role = ? WHERE id = ?`, role, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) SetPwLocked(id int64, locked bool) error {
|
||||||
|
_, err := db.sql.Exec(`UPDATE admins SET pw_locked = ? WHERE id = ?`, boolInt(locked), id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserAgents are the machines a user may use.
|
||||||
|
func (db *DB) UserAgents(adminID int64) map[string]bool {
|
||||||
|
out := map[string]bool{}
|
||||||
|
rows, err := db.sql.Query(`SELECT agent_id FROM admin_agents WHERE admin_id = ?`, adminID)
|
||||||
|
if err != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
if rows.Scan(&id) == nil {
|
||||||
|
out[id] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetUserAgents gives the user exactly these machines.
|
||||||
|
func (db *DB) SetUserAgents(adminID int64, agentIDs []string) error {
|
||||||
|
tx, err := db.sql.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
if _, err := tx.Exec(`DELETE FROM admin_agents WHERE admin_id = ?`, adminID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, id := range agentIDs {
|
||||||
|
if _, err := tx.Exec(`INSERT OR IGNORE INTO admin_agents(admin_id, agent_id) VALUES(?, ?)`, adminID, id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanUseAgent reports whether the user may use the machine.
|
||||||
|
func (db *DB) CanUseAgent(a *Admin, agentID string) bool {
|
||||||
|
if a == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if a.IsAdmin() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
var n int
|
||||||
|
_ = db.sql.QueryRow(`SELECT COUNT(*) FROM admin_agents WHERE admin_id = ? AND agent_id = ?`, a.ID, agentID).Scan(&n)
|
||||||
|
return n > 0
|
||||||
|
}
|
||||||
|
|
||||||
func (db *DB) DeleteAdmin(id int64) error {
|
func (db *DB) DeleteAdmin(id int64) error {
|
||||||
_, err := db.sql.Exec(`DELETE FROM admins WHERE id = ?`, id)
|
_, err := db.sql.Exec(`DELETE FROM admins WHERE id = ?`, id)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AdminKey is a passkey an admin signs in with.
|
||||||
|
type AdminKey struct {
|
||||||
|
ID int64
|
||||||
|
AdminID int64
|
||||||
|
CredID []byte
|
||||||
|
PublicKey []byte // PKIX DER
|
||||||
|
SignCount uint32
|
||||||
|
Name string
|
||||||
|
CreatedAt int64
|
||||||
|
LastUsed int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) AdminByID(id int64) (*Admin, error) {
|
||||||
|
a, err := scanAdmin(db.sql.QueryRow(`SELECT `+adminCols+` FROM admins WHERE id = ?`, id))
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return a, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) AddAdminKey(k AdminKey) error {
|
||||||
|
_, err := db.sql.Exec(
|
||||||
|
`INSERT INTO admin_keys(admin_id, cred_id, public_key, sign_count, name, created_at) VALUES(?, ?, ?, ?, ?, ?)`,
|
||||||
|
k.AdminID, k.CredID, k.PublicKey, k.SignCount, k.Name, time.Now().Unix())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) ListAdminKeys(adminID int64) ([]AdminKey, error) {
|
||||||
|
rows, err := db.sql.Query(
|
||||||
|
`SELECT id, admin_id, cred_id, public_key, sign_count, name, created_at, last_used
|
||||||
|
FROM admin_keys WHERE admin_id = ? ORDER BY id`, adminID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []AdminKey
|
||||||
|
for rows.Next() {
|
||||||
|
var k AdminKey
|
||||||
|
if err := rows.Scan(&k.ID, &k.AdminID, &k.CredID, &k.PublicKey, &k.SignCount, &k.Name, &k.CreatedAt, &k.LastUsed); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasswordOff tells an admin who turned the password off: then only a
|
||||||
|
// passkey signs them in. It holds only while they have one.
|
||||||
|
func (db *DB) PasswordOff(adminID int64) bool {
|
||||||
|
var off int
|
||||||
|
_ = db.sql.QueryRow(`SELECT password_off FROM admins WHERE id = ?`, adminID).Scan(&off)
|
||||||
|
return off == 1 && db.CountAdminKeys(adminID) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminPin is the hash of the admin's PIN, "" when there is none.
|
||||||
|
func (db *DB) AdminPin(adminID int64) string {
|
||||||
|
var h string
|
||||||
|
_ = db.sql.QueryRow(`SELECT pin_hash FROM admins WHERE id = ?`, adminID).Scan(&h)
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) SetAdminPin(adminID int64, pinHash string) error {
|
||||||
|
_, err := db.sql.Exec(`UPDATE admins SET pin_hash = ? WHERE id = ?`, pinHash, adminID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) SetAdminPassword(adminID int64, passHash string) error {
|
||||||
|
_, err := db.sql.Exec(`UPDATE admins SET pass_hash = ? WHERE id = ?`, passHash, adminID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) SetPasswordOff(adminID int64, off bool) error {
|
||||||
|
v := 0
|
||||||
|
if off {
|
||||||
|
v = 1
|
||||||
|
}
|
||||||
|
_, err := db.sql.Exec(`UPDATE admins SET password_off = ? WHERE id = ?`, v, adminID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountAdminKeys is how many passkeys an admin has.
|
||||||
|
func (db *DB) CountAdminKeys(adminID int64) int {
|
||||||
|
var n int
|
||||||
|
_ = db.sql.QueryRow(`SELECT COUNT(*) FROM admin_keys WHERE admin_id = ?`, adminID).Scan(&n)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) AdminKeyByCredID(credID []byte) (*AdminKey, error) {
|
||||||
|
k := &AdminKey{}
|
||||||
|
err := db.sql.QueryRow(
|
||||||
|
`SELECT id, admin_id, cred_id, public_key, sign_count, name, created_at, last_used
|
||||||
|
FROM admin_keys WHERE cred_id = ?`, credID).
|
||||||
|
Scan(&k.ID, &k.AdminID, &k.CredID, &k.PublicKey, &k.SignCount, &k.Name, &k.CreatedAt, &k.LastUsed)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return k, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) UseAdminKey(id int64, signCount uint32) error {
|
||||||
|
_, err := db.sql.Exec(`UPDATE admin_keys SET sign_count = ?, last_used = ? WHERE id = ?`, signCount, time.Now().Unix(), id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteAdminKey removes a passkey; with the last one gone, the password
|
||||||
|
// signs the admin in again.
|
||||||
|
func (db *DB) DeleteAdminKey(adminID, id int64) error {
|
||||||
|
if _, err := db.sql.Exec(`DELETE FROM admin_keys WHERE admin_id = ? AND id = ?`, adminID, id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if db.CountAdminKeys(adminID) == 0 {
|
||||||
|
return db.SetPasswordOff(adminID, false)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) DeleteAdminKeys(adminID int64) error {
|
||||||
|
if _, err := db.sql.Exec(`DELETE FROM admin_keys WHERE admin_id = ?`, adminID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return db.SetPasswordOff(adminID, false)
|
||||||
|
}
|
||||||
|
|
||||||
// --- admin sessions ---
|
// --- admin sessions ---
|
||||||
|
|
||||||
func (db *DB) CreateSession(id string, adminID int64, ttl time.Duration) error {
|
func (db *DB) CreateSession(id string, adminID int64, ttl time.Duration) error {
|
||||||
@@ -200,12 +439,10 @@ func (db *DB) CreateSession(id string, adminID int64, ttl time.Duration) error {
|
|||||||
|
|
||||||
// SessionAdmin returns the admin for a live (non-expired) session, or nil.
|
// SessionAdmin returns the admin for a live (non-expired) session, or nil.
|
||||||
func (db *DB) SessionAdmin(sessionID string) (*Admin, error) {
|
func (db *DB) SessionAdmin(sessionID string) (*Admin, error) {
|
||||||
a := &Admin{}
|
a, err := scanAdmin(db.sql.QueryRow(
|
||||||
err := db.sql.QueryRow(
|
`SELECT a.id, a.username, a.pass_hash, a.role, a.pw_locked
|
||||||
`SELECT a.id, a.username, a.pass_hash
|
|
||||||
FROM admin_sessions s JOIN admins a ON a.id = s.admin_id
|
FROM admin_sessions s JOIN admins a ON a.id = s.admin_id
|
||||||
WHERE s.id = ? AND s.expires_at > ?`, sessionID, time.Now().Unix()).
|
WHERE s.id = ? AND s.expires_at > ?`, sessionID, time.Now().Unix()))
|
||||||
Scan(&a.ID, &a.Username, &a.PassHash)
|
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -230,6 +467,7 @@ type Agent struct {
|
|||||||
Version string
|
Version string
|
||||||
RunUser string // OS user the agent process runs as (reported by the agent)
|
RunUser string // OS user the agent process runs as (reported by the agent)
|
||||||
LoginUser string // OS user terminals are opened as; "" = RunUser
|
LoginUser string // OS user terminals are opened as; "" = RunUser
|
||||||
|
Transport string // auto, ws or lp: how the agent is to reach us
|
||||||
LastSeen time.Time
|
LastSeen time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,8 +497,8 @@ func (db *DB) GetAgent(id string) (*Agent, error) {
|
|||||||
a := &Agent{}
|
a := &Agent{}
|
||||||
var last int64
|
var last int64
|
||||||
err := db.sql.QueryRow(
|
err := db.sql.QueryRow(
|
||||||
`SELECT id, name, hostname, os, arch, token_hash, version, run_user, login_user, last_seen FROM agents WHERE id = ?`, id).
|
`SELECT id, name, hostname, os, arch, token_hash, version, run_user, login_user, transport, last_seen FROM agents WHERE id = ?`, id).
|
||||||
Scan(&a.ID, &a.Name, &a.Hostname, &a.OS, &a.Arch, &a.TokenHash, &a.Version, &a.RunUser, &a.LoginUser, &last)
|
Scan(&a.ID, &a.Name, &a.Hostname, &a.OS, &a.Arch, &a.TokenHash, &a.Version, &a.RunUser, &a.LoginUser, &a.Transport, &last)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -291,6 +529,11 @@ func (db *DB) ListAgents() ([]Agent, error) {
|
|||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (db *DB) SetAgentTransport(id, transport string) error {
|
||||||
|
_, err := db.sql.Exec(`UPDATE agents SET transport = ? WHERE id = ?`, transport, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (db *DB) TouchAgent(id string) error {
|
func (db *DB) TouchAgent(id string) error {
|
||||||
_, err := db.sql.Exec(`UPDATE agents SET last_seen = ? WHERE id = ?`, time.Now().Unix(), id)
|
_, err := db.sql.Exec(`UPDATE agents SET last_seen = ? WHERE id = ?`, time.Now().Unix(), id)
|
||||||
return err
|
return err
|
||||||
@@ -311,6 +554,9 @@ func (db *DB) SetAgentLoginUser(id, user string) error {
|
|||||||
|
|
||||||
func (db *DB) DeleteAgent(id string) error {
|
func (db *DB) DeleteAgent(id string) error {
|
||||||
_, err := db.sql.Exec(`DELETE FROM agents WHERE id = ?`, id)
|
_, err := db.sql.Exec(`DELETE FROM agents WHERE id = ?`, id)
|
||||||
|
if err == nil {
|
||||||
|
_, _ = db.sql.Exec(`DELETE FROM admin_agents WHERE agent_id = ?`, id)
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -487,3 +733,10 @@ func (db *DB) Audit(adminID int64, agentID, action string) {
|
|||||||
`INSERT INTO audit(admin_id, agent_id, action, at) VALUES(?, ?, ?, ?)`,
|
`INSERT INTO audit(admin_id, agent_id, action, at) VALUES(?, ?, ?, ?)`,
|
||||||
adminID, agentID, action, time.Now().Unix())
|
adminID, agentID, action, time.Now().Unix())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func boolInt(b bool) int {
|
||||||
|
if b {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@@ -45,6 +45,7 @@ func (s *Server) handlePoll(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
// The agent sends its version and OS user occasionally; record them when
|
// The agent sends its version and OS user occasionally; record them when
|
||||||
// present. Agents older than 0.0.2 send no user, so keep what we have.
|
// present. Agents older than 0.0.2 send no user, so keep what we have.
|
||||||
|
s.viaDirectA.Store(a.ID, s.viaDirect(r))
|
||||||
v := r.Header.Get("X-Agent-Version")
|
v := r.Header.Get("X-Agent-Version")
|
||||||
u := firstNonEmpty(r.Header.Get("X-Agent-User"), a.RunUser)
|
u := firstNonEmpty(r.Header.Get("X-Agent-User"), a.RunUser)
|
||||||
if v != "" && (v != a.Version || u != a.RunUser) {
|
if v != "" && (v != a.Version || u != a.RunUser) {
|
||||||
@@ -53,8 +54,9 @@ func (s *Server) handlePoll(w http.ResponseWriter, r *http.Request) {
|
|||||||
_ = s.db.TouchAgent(a.ID)
|
_ = s.db.TouchAgent(a.ID)
|
||||||
}
|
}
|
||||||
msg := s.hub.WaitControl(r.Context().Done(), a.ID, pollHold)
|
msg := s.hub.WaitControl(r.Context().Done(), a.ID, pollHold)
|
||||||
// Set after the hold so a port changed meanwhile reaches the agent now.
|
// Set after the hold so a setting changed meanwhile reaches the agent now.
|
||||||
s.setTunnelHeader(w)
|
s.setTunnelHeader(w)
|
||||||
|
w.Header().Set(proto.TransportHeader, s.transportOf(a.ID))
|
||||||
writeJSON(w, msg)
|
writeJSON(w, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,16 +68,22 @@ func (s *Server) setTunnelHeader(w http.ResponseWriter) {
|
|||||||
if addr := s.tunnelAdvert(); addr != "" {
|
if addr := s.tunnelAdvert(); addr != "" {
|
||||||
w.Header().Set(tunnelAddrHeader, addr)
|
w.Header().Set(tunnelAddrHeader, addr)
|
||||||
}
|
}
|
||||||
|
if d := s.db.DirectAddress(); d != "" {
|
||||||
|
w.Header().Set(proto.FallbackHeader, d)
|
||||||
|
w.Header().Set(proto.MainHeader, s.db.HostAddress())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/agent/config - settings an agent picks up when it connects, without
|
// GET /api/agent/config - settings an agent picks up when it connects, without
|
||||||
// waiting out a poll hold.
|
// waiting out a poll hold.
|
||||||
func (s *Server) handleAgentConfig(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleAgentConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
if s.authenticateAgent(r) == nil {
|
a := s.authenticateAgent(r)
|
||||||
|
if a == nil {
|
||||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.setTunnelHeader(w)
|
s.setTunnelHeader(w)
|
||||||
|
w.Header().Set(proto.TransportHeader, s.transportOf(a.ID))
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||||
@@ -87,7 +88,12 @@ func (s *Server) handleLoginSubmit(w http.ResponseWriter, r *http.Request) {
|
|||||||
admin, _ := s.db.AdminByUsername(username)
|
admin, _ := s.db.AdminByUsername(username)
|
||||||
if admin == nil || !checkPassword(admin.PassHash, pw) {
|
if admin == nil || !checkPassword(admin.PassHash, pw) {
|
||||||
s.limiter.fin(ip, false)
|
s.limiter.fin(ip, false)
|
||||||
s.render(w, "login.html", map[string]any{"Error": "Неверный логин или пароль."})
|
s.render(w, "login.html", map[string]any{"Error": "Неверный логин или пароль.", "Username": username})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// An admin who turned the password off signs in with a passkey only.
|
||||||
|
if s.db.PasswordOff(admin.ID) {
|
||||||
|
s.render(w, "login.html", map[string]any{"Error": "Для этого аккаунта вход только по ключу доступа.", "Username": username})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.limiter.fin(ip, true)
|
s.limiter.fin(ip, true)
|
||||||
@@ -116,15 +122,20 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
|||||||
Online bool
|
Online bool
|
||||||
Seen string
|
Seen string
|
||||||
}
|
}
|
||||||
|
me := s.currentAdmin(r)
|
||||||
rows := make([]row, 0, len(agents))
|
rows := make([]row, 0, len(agents))
|
||||||
for _, a := range agents {
|
for _, a := range agents {
|
||||||
|
if !s.db.CanUseAgent(me, a.ID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
on := s.online(&a)
|
on := s.online(&a)
|
||||||
rows = append(rows, row{Agent: a, Online: on, Seen: seenText(on, a.LastSeen)})
|
rows = append(rows, row{Agent: a, Online: on, Seen: seenText(on, a.LastSeen)})
|
||||||
}
|
}
|
||||||
s.render(w, "dashboard.html", map[string]any{
|
s.render(w, "dashboard.html", map[string]any{
|
||||||
"Admin": s.currentAdmin(r),
|
"Admin": me,
|
||||||
"Agents": rows,
|
"IsAdmin": me.IsAdmin(),
|
||||||
"CSRF": csrfToken(r),
|
"Agents": rows,
|
||||||
|
"CSRF": csrfToken(r),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,14 +157,21 @@ func (s *Server) handlePresence(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
type item struct {
|
type item struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Online bool `json:"online"`
|
Name string `json:"name"`
|
||||||
Seen string `json:"seen"`
|
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))
|
out := make([]item, 0, len(agents))
|
||||||
for _, a := range agents {
|
for _, a := range agents {
|
||||||
|
if !s.db.CanUseAgent(me, a.ID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
on := s.online(&a)
|
on := s.online(&a)
|
||||||
out = append(out, item{ID: a.ID, Online: on, Seen: seenText(on, a.LastSeen)})
|
out = append(out, item{ID: a.ID, Name: a.Name, Hostname: a.Hostname, User: a.TerminalUser(), Online: on, Seen: seenText(on, a.LastSeen)})
|
||||||
}
|
}
|
||||||
writeJSON(w, out)
|
writeJSON(w, out)
|
||||||
}
|
}
|
||||||
@@ -199,12 +217,19 @@ func (s *Server) handleAgentEdit(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
data := map[string]any{
|
data := map[string]any{
|
||||||
"Agent": a,
|
"Agent": a,
|
||||||
|
"IsAdmin": s.currentAdmin(r).IsAdmin(),
|
||||||
"CSRF": csrfToken(r),
|
"CSRF": csrfToken(r),
|
||||||
"ServerVersion": Version,
|
"ServerVersion": Version,
|
||||||
"Outdated": a.Version != "" && a.Version != Version,
|
"Outdated": a.Version != "" && a.Version != Version,
|
||||||
"Updated": r.URL.Query().Get("updated") == "1",
|
"Updated": r.URL.Query().Get("updated") == "1",
|
||||||
|
"UpdateMsg": r.URL.Query().Get("umsg"),
|
||||||
|
"UpdateErr": r.URL.Query().Get("uperr"),
|
||||||
"UserOK": r.URL.Query().Get("uok"),
|
"UserOK": r.URL.Query().Get("uok"),
|
||||||
"UserErr": r.URL.Query().Get("uerr"),
|
"UserErr": r.URL.Query().Get("uerr"),
|
||||||
|
"Transport": firstNonEmpty(a.Transport, proto.TransportAuto),
|
||||||
|
"OnWS": s.wsAgents.On(a.ID),
|
||||||
|
"ViaDirect": viaDirectOf(&s.viaDirectA, a.ID),
|
||||||
|
"Online": s.online(a),
|
||||||
}
|
}
|
||||||
// The user list lives on the machine, so it is only known while it is online.
|
// The user list lives on the machine, so it is only known while it is online.
|
||||||
if s.online(a) {
|
if s.online(a) {
|
||||||
@@ -217,6 +242,21 @@ func (s *Server) handleAgentEdit(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.render(w, "edit.html", data)
|
s.render(w, "edit.html", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// POST /agents/{id}/transport - how the agent is to reach the server: auto,
|
||||||
|
// ws or lp. A connected agent picks it up within a poll round.
|
||||||
|
func (s *Server) handleAgentTransport(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !checkCSRF(r) {
|
||||||
|
http.Error(w, "bad csrf", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, t := r.PathValue("id"), r.FormValue("transport")
|
||||||
|
switch t {
|
||||||
|
case proto.TransportAuto, proto.TransportWS, proto.TransportLP:
|
||||||
|
_ = s.db.SetAgentTransport(id, t)
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/agents/"+id+"/edit", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
// usersTaskTimeout bounds how long the edit page waits for the agent.
|
// usersTaskTimeout bounds how long the edit page waits for the agent.
|
||||||
const usersTaskTimeout = 8 * time.Second
|
const usersTaskTimeout = 8 * time.Second
|
||||||
|
|
||||||
@@ -340,17 +380,28 @@ func (s *Server) handleAgentUpdate(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
back := func(q url.Values) { http.Redirect(w, r, "/agents/"+id+"/edit?"+q.Encode(), http.StatusSeeOther) }
|
||||||
if !s.online(a) {
|
if !s.online(a) {
|
||||||
http.Redirect(w, r, "/agents/"+id+"/edit?updated=0", http.StatusSeeOther)
|
back(url.Values{"uperr": {"машина не в сети"}})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
taskID, ch := s.hub.CreateTask(id, proto.TaskSysUpdate, nil)
|
if admin := s.currentAdmin(r); admin != nil {
|
||||||
defer s.hub.DiscardTask(taskID)
|
s.db.Audit(admin.ID, id, "update:"+a.Version+" -> "+Version)
|
||||||
select {
|
}
|
||||||
case <-ch:
|
// The download can take a while on a slow link; under a proxy's timeout.
|
||||||
case <-time.After(30 * time.Second):
|
res, err := s.waitTask(r.Context(), id, proto.TaskSysUpdate, nil, 80*time.Second)
|
||||||
|
switch {
|
||||||
|
case err != nil:
|
||||||
|
back(url.Values{"uperr": {"агент не ответил: " + err.Error()}})
|
||||||
|
case !res.OK:
|
||||||
|
back(url.Values{"uperr": {res.Error}})
|
||||||
|
default:
|
||||||
|
var d struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(res.Data, &d)
|
||||||
|
back(url.Values{"updated": {"1"}, "umsg": {d.Status}})
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, "/agents/"+id+"/edit?updated=1", http.StatusSeeOther)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleAgentRename(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleAgentRename(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -370,7 +421,15 @@ func (s *Server) handleAgentRename(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
admins, _ := s.db.ListAdmins()
|
admins, _ := s.db.ListAdmins()
|
||||||
|
keyCounts, passOff, agentCounts := map[int64]int{}, map[int64]bool{}, map[int64]int{}
|
||||||
|
for _, a := range admins {
|
||||||
|
keyCounts[a.ID] = s.db.CountAdminKeys(a.ID)
|
||||||
|
passOff[a.ID] = s.db.PasswordOff(a.ID)
|
||||||
|
agentCounts[a.ID] = len(s.db.UserAgents(a.ID))
|
||||||
|
}
|
||||||
|
allAgents, _ := s.db.ListAgents()
|
||||||
host := s.db.HostAddress()
|
host := s.db.HostAddress()
|
||||||
|
direct := s.db.DirectAddress()
|
||||||
secret := s.db.EnrollSecret()
|
secret := s.db.EnrollSecret()
|
||||||
code := s.db.InstallCode()
|
code := s.db.InstallCode()
|
||||||
ufwInstalled, ufwActive, ufwRunnable := s.ufw.status()
|
ufwInstalled, ufwActive, ufwRunnable := s.ufw.status()
|
||||||
@@ -378,8 +437,17 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.render(w, "settings.html", map[string]any{
|
s.render(w, "settings.html", map[string]any{
|
||||||
"Admin": s.currentAdmin(r),
|
"Admin": s.currentAdmin(r),
|
||||||
"Host": host,
|
"Host": host,
|
||||||
|
"Direct": direct,
|
||||||
|
"DirectShort": fmt.Sprintf("wget -qO- %s/i/%s | sudo sh", direct, code),
|
||||||
|
"DirectCurl": fmt.Sprintf("curl -fsSL %s/install.sh | sudo HOST=%s FALLBACK_HOST=%s ENROLL_SECRET=%s sh", direct, direct, host, secret),
|
||||||
"Secret": secret,
|
"Secret": secret,
|
||||||
|
"CodeLen": s.db.InstallCodeLen(),
|
||||||
"Admins": admins,
|
"Admins": admins,
|
||||||
|
"KeyCounts": keyCounts,
|
||||||
|
"AgentCounts": agentCounts,
|
||||||
|
"AllAgents": allAgents,
|
||||||
|
"Me": s.currentAdmin(r),
|
||||||
|
"PassOff": passOff,
|
||||||
"CSRF": csrfToken(r),
|
"CSRF": csrfToken(r),
|
||||||
"InstallShort": fmt.Sprintf("wget -qO- %s/i/%s | sudo sh", host, code),
|
"InstallShort": fmt.Sprintf("wget -qO- %s/i/%s | sudo sh", host, code),
|
||||||
"InstallCurl": fmt.Sprintf("curl -fsSL %s/install.sh | sudo HOST=%s ENROLL_SECRET=%s sh", host, host, secret),
|
"InstallCurl": fmt.Sprintf("curl -fsSL %s/install.sh | sudo HOST=%s ENROLL_SECRET=%s sh", host, host, secret),
|
||||||
@@ -397,6 +465,7 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
"TunnelCustom": s.db.TunnelPort() > 0,
|
"TunnelCustom": s.db.TunnelPort() > 0,
|
||||||
"Error": map[string]string{
|
"Error": map[string]string{
|
||||||
"tunnel_host": "Некорректный хост туннеля: нужен IP или имя без схемы и порта.",
|
"tunnel_host": "Некорректный хост туннеля: нужен IP или имя без схемы и порта.",
|
||||||
|
"host": "Некорректный адрес: нужен URL вида https://panel.example.com, без пути.",
|
||||||
"tunnel_port": "Некорректный порт туннеля.",
|
"tunnel_port": "Некорректный порт туннеля.",
|
||||||
"ufw_port": "Некорректное правило ufw: нужен порт 1-65535 и протокол tcp или udp.",
|
"ufw_port": "Некорректное правило ufw: нужен порт 1-65535 и протокол tcp или udp.",
|
||||||
"ufw_off": "Правило ufw не добавлено: ufw выключен или интеграция с ним отключена.",
|
"ufw_off": "Правило ufw не добавлено: ufw выключен или интеграция с ним отключена.",
|
||||||
@@ -470,9 +539,20 @@ func (s *Server) handleSetHost(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
host := strings.TrimRight(strings.TrimSpace(r.FormValue("host")), "/")
|
host := strings.TrimRight(strings.TrimSpace(r.FormValue("host")), "/")
|
||||||
|
direct := strings.TrimRight(strings.TrimSpace(r.FormValue("direct")), "/")
|
||||||
|
for _, v := range []string{host, direct} {
|
||||||
|
if v == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if u, err := url.Parse(v); err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || (u.Path != "" && u.Path != "/") {
|
||||||
|
http.Redirect(w, r, "/settings?err=host", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
if host != "" {
|
if host != "" {
|
||||||
_ = s.db.SetConfig(cfgHostAddress, host)
|
_ = s.db.SetConfig(cfgHostAddress, host)
|
||||||
}
|
}
|
||||||
|
_ = s.db.SetConfig(cfgDirectAddr, direct)
|
||||||
http.Redirect(w, r, "/settings", http.StatusSeeOther)
|
http.Redirect(w, r, "/settings", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -483,7 +563,22 @@ func (s *Server) handleRotateSecret(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
_ = s.db.SetConfig(cfgEnrollSecret, randToken())
|
_ = s.db.SetConfig(cfgEnrollSecret, randToken())
|
||||||
// Rotate the short install code too so old /i/<code> links stop working.
|
// Rotate the short install code too so old /i/<code> links stop working.
|
||||||
_ = s.db.SetConfig(cfgInstallCode, shortCode())
|
_ = s.db.SetConfig(cfgInstallCode, shortCode(s.db.InstallCodeLen()))
|
||||||
|
http.Redirect(w, r, "/settings", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /settings/install/code - a new install code, of the length given:
|
||||||
|
// the old /i/<code> link stops working.
|
||||||
|
func (s *Server) handleInstallCode(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !checkCSRF(r) {
|
||||||
|
http.Error(w, "bad csrf", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n, err := strconv.Atoi(r.FormValue("len")); err == nil {
|
||||||
|
n = min(installCodeMax, max(installCodeMin, n))
|
||||||
|
_ = s.db.SetConfig(cfgInstallLen, strconv.Itoa(n))
|
||||||
|
}
|
||||||
|
_ = s.db.SetConfig(cfgInstallCode, shortCode(s.db.InstallCodeLen()))
|
||||||
http.Redirect(w, r, "/settings", http.StatusSeeOther)
|
http.Redirect(w, r, "/settings", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -494,12 +589,21 @@ func (s *Server) handleAddAdmin(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
username := strings.TrimSpace(r.FormValue("username"))
|
username := strings.TrimSpace(r.FormValue("username"))
|
||||||
pw := r.FormValue("password")
|
pw := r.FormValue("password")
|
||||||
|
role := RoleAdmin
|
||||||
|
if r.FormValue("role") == RoleUser {
|
||||||
|
role = RoleUser
|
||||||
|
}
|
||||||
if username != "" && len(pw) >= 8 {
|
if username != "" && len(pw) >= 8 {
|
||||||
if hash, err := hashPassword(pw); err == nil {
|
if hash, err := hashPassword(pw); err == nil {
|
||||||
_ = s.db.CreateAdmin(username, hash)
|
if id, err := s.db.CreateUser(username, hash, role); err == nil && role == RoleUser {
|
||||||
|
_ = s.db.SetUserAgents(id, r.Form["agents"])
|
||||||
|
}
|
||||||
|
if me := s.currentAdmin(r); me != nil {
|
||||||
|
s.db.Audit(me.ID, "", "user_add:"+username+":"+role)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, "/settings", http.StatusSeeOther)
|
http.Redirect(w, r, "/settings#users", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleDeleteAdmin(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleDeleteAdmin(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -509,7 +613,7 @@ func (s *Server) handleDeleteAdmin(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
id, _ := strconv.ParseInt(r.FormValue("id"), 10, 64)
|
id, _ := strconv.ParseInt(r.FormValue("id"), 10, 64)
|
||||||
// Never delete the last admin.
|
// Never delete the last admin.
|
||||||
if n, _ := s.db.CountAdmins(); n > 1 {
|
if u, _ := s.db.AdminByID(id); u != nil && (!u.IsAdmin() || s.db.CountRole(RoleAdmin) > 1) {
|
||||||
_ = s.db.DeleteAdmin(id)
|
_ = s.db.DeleteAdmin(id)
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, "/settings", http.StatusSeeOther)
|
http.Redirect(w, r, "/settings", http.StatusSeeOther)
|
||||||
@@ -562,7 +666,7 @@ func (s *Server) handleUfwRevoke(w http.ResponseWriter, r *http.Request) {
|
|||||||
// handleInstallScript serves the generic installer; the secret is NOT baked in,
|
// handleInstallScript serves the generic installer; the secret is NOT baked in,
|
||||||
// so it must be supplied via env or the interactive prompt.
|
// so it must be supplied via env or the interactive prompt.
|
||||||
func (s *Server) handleInstallScript(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleInstallScript(w http.ResponseWriter, r *http.Request) {
|
||||||
s.renderInstall(w, "")
|
s.renderInstall(w, r, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleShortInstall serves the installer behind the short /i/<code> link with
|
// handleShortInstall serves the installer behind the short /i/<code> link with
|
||||||
@@ -570,21 +674,49 @@ func (s *Server) handleInstallScript(w http.ResponseWriter, r *http.Request) {
|
|||||||
//
|
//
|
||||||
// wget -qO- https://host/i/<code> | sudo sh
|
// wget -qO- https://host/i/<code> | sudo sh
|
||||||
func (s *Server) handleShortInstall(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleShortInstall(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// A short code can be guessed: wrong ones count against the address
|
||||||
|
// like wrong passwords do.
|
||||||
|
ip := clientIP(r)
|
||||||
|
if s.limiter.blocked(ip) {
|
||||||
|
http.Error(w, "too many attempts", http.StatusTooManyRequests)
|
||||||
|
return
|
||||||
|
}
|
||||||
code := r.PathValue("code")
|
code := r.PathValue("code")
|
||||||
want := s.db.InstallCode()
|
want := s.db.InstallCode()
|
||||||
if want == "" || !constantTimeEqual(code, want) {
|
if want == "" || !constantTimeEqual(code, want) {
|
||||||
|
s.limiter.fin(ip, false)
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.renderInstall(w, s.db.EnrollSecret())
|
s.renderInstall(w, r, s.db.EnrollSecret())
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderInstall writes install.sh with the current host and an optional secret.
|
// viaDirect reports whether the request came to the direct address.
|
||||||
func (s *Server) renderInstall(w http.ResponseWriter, secret string) {
|
func (s *Server) viaDirect(r *http.Request) bool {
|
||||||
|
d := directHost(s.db.DirectAddress())
|
||||||
|
if d == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
host := r.Host
|
||||||
|
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||||
|
host = h
|
||||||
|
}
|
||||||
|
return strings.EqualFold(host, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderInstall writes install.sh with the server's addresses and an
|
||||||
|
// optional secret. Fetched from the direct address, it makes that the
|
||||||
|
// agent's address and the main one its fallback; otherwise the other way.
|
||||||
|
func (s *Server) renderInstall(w http.ResponseWriter, r *http.Request, secret string) {
|
||||||
|
host, fallback := s.db.HostAddress(), s.db.DirectAddress()
|
||||||
|
if s.viaDirect(r) {
|
||||||
|
host, fallback = fallback, host
|
||||||
|
}
|
||||||
w.Header().Set("Content-Type", "text/x-shellscript; charset=utf-8")
|
w.Header().Set("Content-Type", "text/x-shellscript; charset=utf-8")
|
||||||
if err := s.installTmpl.Execute(w, map[string]any{
|
if err := s.installTmpl.Execute(w, map[string]any{
|
||||||
"Host": s.db.HostAddress(),
|
"Host": host,
|
||||||
"Secret": secret,
|
"Fallback": fallback,
|
||||||
|
"Secret": secret,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Printf("install.sh render: %v", err)
|
log.Printf("install.sh render: %v", err)
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
@@ -596,3 +728,11 @@ func (s *Server) downloadHandler() http.Handler {
|
|||||||
// install one-liner works before the machine is enrolled.
|
// install one-liner works before the machine is enrolled.
|
||||||
return http.FileServer(http.Dir(s.distDir))
|
return http.FileServer(http.Dir(s.distDir))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// viaDirectOf tells whether the agent last reached the server at the direct
|
||||||
|
// address.
|
||||||
|
func viaDirectOf(m *sync.Map, id string) bool {
|
||||||
|
v, _ := m.Load(id)
|
||||||
|
b, _ := v.(bool)
|
||||||
|
return b
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
@@ -10,15 +11,24 @@ import (
|
|||||||
|
|
||||||
// fsOps maps the URL op segment to a proto task type.
|
// fsOps maps the URL op segment to a proto task type.
|
||||||
var fsOps = map[string]string{
|
var fsOps = map[string]string{
|
||||||
"list": proto.TaskFSList,
|
"list": proto.TaskFSList,
|
||||||
"read": proto.TaskFSRead,
|
"read": proto.TaskFSRead,
|
||||||
"write": proto.TaskFSWrite,
|
"write": proto.TaskFSWrite,
|
||||||
"mkdir": proto.TaskFSMkdir,
|
"mkdir": proto.TaskFSMkdir,
|
||||||
"delete": proto.TaskFSDelete,
|
"delete": proto.TaskFSDelete,
|
||||||
"deltree": proto.TaskFSDelTree,
|
"deltree": proto.TaskFSDelTree,
|
||||||
"unzip": proto.TaskFSUnzip,
|
"unzip": proto.TaskFSUnzip,
|
||||||
"zip": proto.TaskFSZip,
|
"zip": proto.TaskFSZip,
|
||||||
"move": proto.TaskFSMove,
|
"move": proto.TaskFSMove,
|
||||||
|
"stat": proto.TaskFSStat,
|
||||||
|
"chmod": proto.TaskFSChmod,
|
||||||
|
"copy": proto.TaskFSCopy,
|
||||||
|
"db.tables": proto.TaskDBTables,
|
||||||
|
"db.rows": proto.TaskDBRows,
|
||||||
|
"db.set": proto.TaskDBSet,
|
||||||
|
"db.add": proto.TaskDBAdd,
|
||||||
|
"db.del": proto.TaskDBDel,
|
||||||
|
"db.sql": proto.TaskDBSQL,
|
||||||
}
|
}
|
||||||
|
|
||||||
// fsTaskTimeout bounds how long we wait for an agent to answer a file op.
|
// fsTaskTimeout bounds how long we wait for an agent to answer a file op.
|
||||||
@@ -60,6 +70,26 @@ func (s *Server) handleFSOp(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Changes to a database go to the audit log, with what was done.
|
||||||
|
if op == "db.set" || op == "db.add" || op == "db.del" || op == "db.sql" {
|
||||||
|
var d struct {
|
||||||
|
Path, Table, Column, SQL string
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(args, &d)
|
||||||
|
what := d.Path + " " + d.Table
|
||||||
|
if d.Column != "" {
|
||||||
|
what += "." + d.Column
|
||||||
|
}
|
||||||
|
if d.SQL != "" {
|
||||||
|
what += " " + d.SQL
|
||||||
|
}
|
||||||
|
if len(what) > 300 {
|
||||||
|
what = what[:300]
|
||||||
|
}
|
||||||
|
if admin := s.currentAdmin(r); admin != nil {
|
||||||
|
s.db.Audit(admin.ID, agentID, op+":"+what)
|
||||||
|
}
|
||||||
|
}
|
||||||
taskID, resultCh := s.hub.CreateTask(agentID, taskType, args)
|
taskID, resultCh := s.hub.CreateTask(agentID, taskType, args)
|
||||||
defer s.hub.DiscardTask(taskID)
|
defer s.hub.DiscardTask(taskID)
|
||||||
|
|
||||||
|
|||||||
@@ -114,6 +114,10 @@ func (s *Server) handleForwardDelete(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
rid, _ := strconv.ParseInt(r.PathValue("rid"), 10, 64)
|
rid, _ := strconv.ParseInt(r.PathValue("rid"), 10, 64)
|
||||||
rule, _ := s.db.GetForwardRule(rid)
|
rule, _ := s.db.GetForwardRule(rid)
|
||||||
|
if rule == nil || !s.db.CanUseAgent(s.currentAdmin(r), rule.AgentID) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
s.removeForward(rid)
|
s.removeForward(rid)
|
||||||
dest := "/"
|
dest := "/"
|
||||||
if rule != nil {
|
if rule != nil {
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/coder/websocket"
|
||||||
|
"github.com/coder/websocket/wsjson"
|
||||||
|
|
||||||
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||||
)
|
)
|
||||||
@@ -12,7 +21,9 @@ import (
|
|||||||
// same transport the agent uses), so it works through any reverse proxy without
|
// same transport the agent uses), so it works through any reverse proxy without
|
||||||
// WebSocket upgrade support.
|
// WebSocket upgrade support.
|
||||||
|
|
||||||
// POST /api/panel/term/{agentID}/open - create a terminal session.
|
// POST /api/panel/term/{agentID}/open - create a terminal session: a shell
|
||||||
|
// on the agent, or with {"ssh": {...}} in the body a shell the agent opens
|
||||||
|
// by SSH on another host.
|
||||||
func (s *Server) handlePanelOpen(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handlePanelOpen(w http.ResponseWriter, r *http.Request) {
|
||||||
agentID := r.PathValue("agentID")
|
agentID := r.PathValue("agentID")
|
||||||
agent, err := s.db.GetAgent(agentID)
|
agent, err := s.db.GetAgent(agentID)
|
||||||
@@ -21,6 +32,33 @@ func (s *Server) handlePanelOpen(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
var body struct {
|
||||||
|
SSH *proto.SSHTarget `json:"ssh"`
|
||||||
|
}
|
||||||
|
if r.ContentLength != 0 {
|
||||||
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&body); err != nil && err != io.EOF {
|
||||||
|
http.Error(w, "bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if t := body.SSH; t != nil {
|
||||||
|
t.Host, t.User = strings.TrimSpace(t.Host), strings.TrimSpace(t.User)
|
||||||
|
if t.Port == 0 {
|
||||||
|
t.Port = 22
|
||||||
|
}
|
||||||
|
if t.Host == "" || t.User == "" || t.Port < 1 || t.Port > 65535 || strings.ContainsAny(t.Host+t.User, " \t\r\n/") {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{"error": "нужны хост, порт 1-65535 и пользователь"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sess := s.hub.CreateSSH(agentID, *t)
|
||||||
|
if admin := s.currentAdmin(r); admin != nil {
|
||||||
|
s.db.Audit(admin.ID, agentID, "open_ssh:"+t.User+"@"+net.JoinHostPort(t.Host, strconv.Itoa(t.Port)))
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]string{"session_id": sess.ID})
|
||||||
|
return
|
||||||
|
}
|
||||||
sess := s.hub.CreateSession(agentID, agent.LoginUser)
|
sess := s.hub.CreateSession(agentID, agent.LoginUser)
|
||||||
if admin := s.currentAdmin(r); admin != nil {
|
if admin := s.currentAdmin(r); admin != nil {
|
||||||
s.db.Audit(admin.ID, agentID, "open_terminal:"+agent.TerminalUser())
|
s.db.Audit(admin.ID, agentID, "open_terminal:"+agent.TerminalUser())
|
||||||
@@ -66,8 +104,14 @@ func (s *Server) handlePanelIn(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "bad request", http.StatusBadRequest)
|
http.Error(w, "bad request", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
takePanelIn(sess, m)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// takePanelIn passes the browser's keystrokes or resize on to the agent; a
|
||||||
|
// repeat of one already taken is dropped.
|
||||||
|
func takePanelIn(sess *Session, m panelInMsg) {
|
||||||
if m.Seq > 0 && !sess.TakeSeq(m.Seq) {
|
if m.Seq > 0 && !sess.TakeSeq(m.Seq) {
|
||||||
w.WriteHeader(http.StatusNoContent) // a repeat of one already taken
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
switch m.T {
|
switch m.T {
|
||||||
@@ -76,7 +120,70 @@ func (s *Server) handlePanelIn(w http.ResponseWriter, r *http.Request) {
|
|||||||
case "r":
|
case "r":
|
||||||
sess.PushIn(proto.InFrame{Type: proto.FrameResize, Cols: m.Cols, Rows: m.Rows})
|
sess.PushIn(proto.InFrame{Type: proto.FrameResize, Cols: m.Cols, Rows: m.Rows})
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
}
|
||||||
|
|
||||||
|
// GET /api/panel/term/{sid}/ws - the browser's terminal on a WebSocket: what
|
||||||
|
// the "in" requests send comes as messages, and output goes out as soon as
|
||||||
|
// it is there, in the "out" answers' shape. The page falls back to those
|
||||||
|
// requests when this does not connect.
|
||||||
|
func (s *Server) handlePanelTermWS(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sess := s.hub.Session(r.PathValue("sid"))
|
||||||
|
if sess == nil {
|
||||||
|
http.Error(w, "closed", http.StatusGone)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{CompressionMode: websocket.CompressionDisabled})
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
conn.SetReadLimit(1 << 20)
|
||||||
|
defer conn.CloseNow()
|
||||||
|
ctx, cancel := context.WithCancel(r.Context())
|
||||||
|
defer cancel()
|
||||||
|
go func() {
|
||||||
|
defer cancel()
|
||||||
|
for ctx.Err() == nil {
|
||||||
|
msgs, alive := sess.PollOutUntil(ctx.Done(), pollHold)
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := map[string]any{"frames": msgs}
|
||||||
|
if !alive {
|
||||||
|
out = map[string]any{"closed": true}
|
||||||
|
}
|
||||||
|
wctx, wcancel := context.WithTimeout(ctx, 30*time.Second)
|
||||||
|
err := wsjson.Write(wctx, conn, out)
|
||||||
|
wcancel()
|
||||||
|
if err != nil || !alive {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
t := time.NewTicker(wsPing)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
pctx, pcancel := context.WithTimeout(ctx, 15*time.Second)
|
||||||
|
err := conn.Ping(pctx)
|
||||||
|
pcancel()
|
||||||
|
if err != nil {
|
||||||
|
cancel()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
for {
|
||||||
|
var m panelInMsg
|
||||||
|
if err := wsjson.Read(ctx, conn, &m); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
takePanelIn(sess, m)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/panel/term/{sid}/close - tear the session down.
|
// POST /api/panel/term/{sid}/close - tear the session down.
|
||||||
|
|||||||
@@ -100,6 +100,13 @@ func (h *Hub) CreateSession(agentID, user string) *Session {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateSSH registers a terminal session the agent opens by SSH to t.
|
||||||
|
func (h *Hub) CreateSSH(agentID string, t proto.SSHTarget) *Session {
|
||||||
|
s := h.newSession(agentID)
|
||||||
|
h.enqueueJob(agentID, proto.ControlMessage{Type: proto.CtrlOpenSSH, SessionID: s.ID, SSH: &t})
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
// CreateForward registers a port-forward stream (agent dials 127.0.0.1:port).
|
// CreateForward registers a port-forward stream (agent dials 127.0.0.1:port).
|
||||||
func (h *Hub) CreateForward(agentID string, port int) *Session {
|
func (h *Hub) CreateForward(agentID string, port int) *Session {
|
||||||
s := h.newSession(agentID)
|
s := h.newSession(agentID)
|
||||||
@@ -275,6 +282,18 @@ func (s *Session) PushIn(f proto.InFrame) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Unpoll puts frames taken by PollIn back in front, for frames that could not
|
||||||
|
// be delivered.
|
||||||
|
func (s *Session) Unpoll(frames []proto.InFrame) {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.inBuf = append(append([]proto.InFrame{}, frames...), s.inBuf...)
|
||||||
|
s.mu.Unlock()
|
||||||
|
select {
|
||||||
|
case s.inSignal <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// PollIn drains queued downstream frames, blocking up to timeout for the first.
|
// PollIn drains queued downstream frames, blocking up to timeout for the first.
|
||||||
func (s *Session) PollIn(timeout time.Duration) ([]proto.InFrame, bool) {
|
func (s *Session) PollIn(timeout time.Duration) ([]proto.InFrame, bool) {
|
||||||
deadline := time.After(timeout)
|
deadline := time.After(timeout)
|
||||||
@@ -312,10 +331,18 @@ func (s *Session) Out() <-chan proto.OutMessage { return s.out }
|
|||||||
|
|
||||||
// PollOut drains queued upstream messages, blocking up to timeout for the first.
|
// PollOut drains queued upstream messages, blocking up to timeout for the first.
|
||||||
func (s *Session) PollOut(timeout time.Duration) ([]proto.OutMessage, bool) {
|
func (s *Session) PollOut(timeout time.Duration) ([]proto.OutMessage, bool) {
|
||||||
|
return s.PollOutUntil(nil, timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PollOutUntil is PollOut that also gives up when done closes, taking
|
||||||
|
// nothing: for a reader that has gone, so no output is lost on it.
|
||||||
|
func (s *Session) PollOutUntil(done <-chan struct{}, timeout time.Duration) ([]proto.OutMessage, bool) {
|
||||||
var batch []proto.OutMessage
|
var batch []proto.OutMessage
|
||||||
select {
|
select {
|
||||||
case m := <-s.out:
|
case m := <-s.out:
|
||||||
batch = append(batch, m)
|
batch = append(batch, m)
|
||||||
|
case <-done:
|
||||||
|
return nil, true
|
||||||
case <-time.After(timeout):
|
case <-time.After(timeout):
|
||||||
return nil, true
|
return nil, true
|
||||||
case <-s.closed:
|
case <-s.closed:
|
||||||
|
|||||||
@@ -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})
|
||||||
|
}
|
||||||
+121
-40
@@ -55,10 +55,16 @@ type Server struct {
|
|||||||
tunnelMu sync.Mutex // serializes port changes
|
tunnelMu sync.Mutex // serializes port changes
|
||||||
tunnelPort atomic.Int64
|
tunnelPort atomic.Int64
|
||||||
|
|
||||||
stats statCache // last load sample per agent, for the status page
|
stats statCache // last load sample per agent, for the status page
|
||||||
relays speedRelays // speed tests that run through the browser
|
relays speedRelays // speed tests that run through the browser
|
||||||
meters speedMeters // byte counts of the speed tests under way
|
meters speedMeters // byte counts of the speed tests under way
|
||||||
xfers xfers // files on their way between a browser and an agent
|
xfers xfers // files on their way between a browser and an agent
|
||||||
|
ship shipping // files on their way from one machine to another
|
||||||
|
viaDirectA sync.Map // agent id -> true while it reaches the server at the direct address
|
||||||
|
challenges challenges // passkey challenges handed out
|
||||||
|
wsAgents wsAgents // agents on their WebSocket now
|
||||||
|
pinMisses pinMisses // wrong PINs per session
|
||||||
|
assets map[string]string // static file -> hash of its content
|
||||||
}
|
}
|
||||||
|
|
||||||
// New builds the server, parsing embedded templates.
|
// New builds the server, parsing embedded templates.
|
||||||
@@ -66,7 +72,15 @@ func New(opts Options) (*Server, error) {
|
|||||||
if err := opts.DB.ensureDefaults(); err != nil {
|
if err := opts.DB.ensureDefaults(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
tmpl, err := template.ParseFS(webFS, "web/templates/*.html")
|
mainHost = func() string { return directHost(opts.DB.HostAddress()) }
|
||||||
|
staticFS, _ := fs.Sub(webFS, "web/static")
|
||||||
|
assets := assetHashes(staticFS)
|
||||||
|
funcs := template.FuncMap{
|
||||||
|
// asset is a static file's URL with its content hash, so a new
|
||||||
|
// release is a new URL that no browser or proxy has cached.
|
||||||
|
"asset": func(name string) string { return "/static/" + name + "?v=" + assets[name] },
|
||||||
|
}
|
||||||
|
tmpl, err := template.New("").Funcs(funcs).ParseFS(webFS, "web/templates/*.html")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -92,6 +106,7 @@ func New(opts Options) (*Server, error) {
|
|||||||
distDir: opts.DistDir,
|
distDir: opts.DistDir,
|
||||||
secureCookies: opts.SecureCookies,
|
secureCookies: opts.SecureCookies,
|
||||||
installTmpl: installTmpl,
|
installTmpl: installTmpl,
|
||||||
|
assets: assets,
|
||||||
}
|
}
|
||||||
s.ufw = newUfwManager(opts.DB, s.TunnelPort)
|
s.ufw = newUfwManager(opts.DB, s.TunnelPort)
|
||||||
// Start the direct forward tunnel (TCP mux + UDP), on the port from the
|
// Start the direct forward tunnel (TCP mux + UDP), on the port from the
|
||||||
@@ -126,13 +141,19 @@ func (s *Server) tunnelListenAddr(port int) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// tunnelAdvert is the tunnel address handed to agents as "host:port". The host
|
// tunnelAdvert is the tunnel address handed to agents as "host:port". The host
|
||||||
// is empty unless one is set in Settings; agents then use the panel's host.
|
// is the one set in Settings, else the direct address's (the main one may be
|
||||||
|
// behind a proxy that carries no raw TCP), else empty: agents then use the
|
||||||
|
// panel's host.
|
||||||
func (s *Server) tunnelAdvert() string {
|
func (s *Server) tunnelAdvert() string {
|
||||||
port := s.TunnelPort()
|
port := s.TunnelPort()
|
||||||
if port == 0 {
|
if port == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return net.JoinHostPort(s.db.TunnelHost(), strconv.Itoa(port))
|
host := s.db.TunnelHost()
|
||||||
|
if host == "" {
|
||||||
|
host = directHost(s.db.DirectAddress())
|
||||||
|
}
|
||||||
|
return net.JoinHostPort(host, strconv.Itoa(port))
|
||||||
}
|
}
|
||||||
|
|
||||||
// setTunnelPort moves the direct tunnel to another port. Connected agents are
|
// setTunnelPort moves the direct tunnel to another port. Connected agents are
|
||||||
@@ -186,7 +207,7 @@ func (s *Server) Handler() http.Handler {
|
|||||||
|
|
||||||
// Static assets (xterm.js etc.) from the embedded FS.
|
// Static assets (xterm.js etc.) from the embedded FS.
|
||||||
staticFS, _ := fs.Sub(webFS, "web/static")
|
staticFS, _ := fs.Sub(webFS, "web/static")
|
||||||
mux.Handle("GET /static/", http.StripPrefix("/static/", staticHandler(staticFS)))
|
mux.Handle("GET /static/", http.StripPrefix("/static/", staticHandler(staticFS, s.assets)))
|
||||||
|
|
||||||
// Public: install script + agent binary downloads.
|
// Public: install script + agent binary downloads.
|
||||||
mux.HandleFunc("GET /install.sh", s.handleInstallScript)
|
mux.HandleFunc("GET /install.sh", s.handleInstallScript)
|
||||||
@@ -201,6 +222,8 @@ func (s *Server) Handler() http.Handler {
|
|||||||
mux.HandleFunc("GET /login", s.handleLoginForm)
|
mux.HandleFunc("GET /login", s.handleLoginForm)
|
||||||
mux.HandleFunc("POST /login", s.handleLoginSubmit)
|
mux.HandleFunc("POST /login", s.handleLoginSubmit)
|
||||||
mux.HandleFunc("POST /logout", s.handleLogout)
|
mux.HandleFunc("POST /logout", s.handleLogout)
|
||||||
|
mux.HandleFunc("POST /api/login/key/begin", s.handleKeyLoginBegin)
|
||||||
|
mux.HandleFunc("POST /api/login/key/finish", s.handleKeyLoginFinish)
|
||||||
|
|
||||||
// Panel (admin-only).
|
// Panel (admin-only).
|
||||||
mux.Handle("GET /{$}", s.requireAdmin(s.handleDashboard))
|
mux.Handle("GET /{$}", s.requireAdmin(s.handleDashboard))
|
||||||
@@ -212,22 +235,37 @@ func (s *Server) Handler() http.Handler {
|
|||||||
mux.Handle("POST /agents/{id}/rename", s.requireAdmin(s.handleAgentRename))
|
mux.Handle("POST /agents/{id}/rename", s.requireAdmin(s.handleAgentRename))
|
||||||
mux.Handle("POST /agents/{id}/update", s.requireAdmin(s.handleAgentUpdate))
|
mux.Handle("POST /agents/{id}/update", s.requireAdmin(s.handleAgentUpdate))
|
||||||
mux.Handle("POST /agents/{id}/user", s.requireAdmin(s.handleAgentSetUser))
|
mux.Handle("POST /agents/{id}/user", s.requireAdmin(s.handleAgentSetUser))
|
||||||
|
mux.Handle("POST /agents/{id}/transport", s.requireAdmin(s.handleAgentTransport))
|
||||||
mux.Handle("POST /agents/{id}/users/add", s.requireAdmin(s.handleAgentAddUser))
|
mux.Handle("POST /agents/{id}/users/add", s.requireAdmin(s.handleAgentAddUser))
|
||||||
mux.Handle("POST /agents/{id}/delete", s.requireAdmin(s.handleAgentDelete))
|
mux.Handle("POST /agents/{id}/delete", s.adminsOnly(s.handleAgentDelete))
|
||||||
mux.Handle("GET /settings", s.requireAdmin(s.handleSettings))
|
mux.Handle("GET /settings", s.adminsOnly(s.handleSettings))
|
||||||
mux.Handle("POST /settings/host", s.requireAdmin(s.handleSetHost))
|
mux.Handle("POST /settings/host", s.adminsOnly(s.handleSetHost))
|
||||||
mux.Handle("POST /settings/tunnel", s.requireAdmin(s.handleSetTunnel))
|
mux.Handle("POST /settings/tunnel", s.adminsOnly(s.handleSetTunnel))
|
||||||
mux.Handle("POST /settings/secret/rotate", s.requireAdmin(s.handleRotateSecret))
|
mux.Handle("POST /settings/secret/rotate", s.adminsOnly(s.handleRotateSecret))
|
||||||
mux.Handle("POST /settings/admins/add", s.requireAdmin(s.handleAddAdmin))
|
mux.Handle("POST /settings/install/code", s.adminsOnly(s.handleInstallCode))
|
||||||
mux.Handle("POST /settings/admins/delete", s.requireAdmin(s.handleDeleteAdmin))
|
mux.Handle("POST /settings/admins/add", s.adminsOnly(s.handleAddAdmin))
|
||||||
mux.Handle("POST /settings/ufw/toggle", s.requireAdmin(s.handleUfwToggle))
|
mux.Handle("POST /settings/admins/delete", s.adminsOnly(s.handleDeleteAdmin))
|
||||||
mux.Handle("POST /settings/ufw/add", s.requireAdmin(s.handleUfwAdd))
|
mux.Handle("GET /api/panel/users/{id}/agents", s.adminsOnly(s.handleUserAgents))
|
||||||
mux.Handle("POST /settings/ufw/revoke", s.requireAdmin(s.handleUfwRevoke))
|
mux.Handle("POST /api/panel/users/{id}", s.adminsOnly(s.handleUserEdit))
|
||||||
|
mux.Handle("POST /settings/admins/keys/reset", s.adminsOnly(s.handleResetAdminKeys))
|
||||||
|
mux.Handle("GET /api/panel/account", s.requireAdmin(s.handleAccount))
|
||||||
|
mux.Handle("POST /api/panel/account/keys/begin", s.requireAdmin(s.handleKeyRegisterBegin))
|
||||||
|
mux.Handle("POST /api/panel/account/keys/finish", s.requireAdmin(s.handleKeyRegisterFinish))
|
||||||
|
mux.Handle("POST /api/panel/account/keys/{id}/delete", s.requireAdmin(s.handleKeyDelete))
|
||||||
|
mux.Handle("POST /api/panel/account/password", s.requireAdmin(s.handlePasswordToggle))
|
||||||
|
mux.Handle("POST /api/panel/account/pin", s.requireAdmin(s.handlePinSet))
|
||||||
|
mux.Handle("GET /api/panel/pin", s.requireAdmin(s.handlePinState))
|
||||||
|
mux.Handle("POST /api/panel/pin/check", s.requireAdmin(s.handlePinCheck))
|
||||||
|
mux.Handle("POST /api/panel/account/password/change", s.requireAdmin(s.handlePasswordChange))
|
||||||
|
mux.Handle("POST /settings/ufw/toggle", s.adminsOnly(s.handleUfwToggle))
|
||||||
|
mux.Handle("POST /settings/ufw/add", s.adminsOnly(s.handleUfwAdd))
|
||||||
|
mux.Handle("POST /settings/ufw/revoke", s.adminsOnly(s.handleUfwRevoke))
|
||||||
|
|
||||||
// Panel terminal over HTTP long-polling (admin-only).
|
// Panel terminal over HTTP long-polling (admin-only).
|
||||||
mux.Handle("POST /api/panel/term/{agentID}/open", s.requireAdmin(s.handlePanelOpen))
|
mux.Handle("POST /api/panel/term/{agentID}/open", s.requireAdmin(s.handlePanelOpen))
|
||||||
mux.Handle("GET /api/panel/term/{sid}/out", s.requireAdmin(s.handlePanelOut))
|
mux.Handle("GET /api/panel/term/{sid}/out", s.requireAdmin(s.handlePanelOut))
|
||||||
mux.Handle("POST /api/panel/term/{sid}/in", s.requireAdmin(s.handlePanelIn))
|
mux.Handle("POST /api/panel/term/{sid}/in", s.requireAdmin(s.handlePanelIn))
|
||||||
|
mux.Handle("GET /api/panel/term/{sid}/ws", s.requireAdmin(s.handlePanelTermWS))
|
||||||
mux.Handle("POST /api/panel/term/{sid}/close", s.requireAdmin(s.handlePanelClose))
|
mux.Handle("POST /api/panel/term/{sid}/close", s.requireAdmin(s.handlePanelClose))
|
||||||
|
|
||||||
// Panel file manager (admin-only).
|
// Panel file manager (admin-only).
|
||||||
@@ -236,6 +274,9 @@ func (s *Server) Handler() http.Handler {
|
|||||||
mux.Handle("PUT /api/panel/fs/xfer/{xid}/{i}", s.requireAdmin(s.handleXferPut))
|
mux.Handle("PUT /api/panel/fs/xfer/{xid}/{i}", s.requireAdmin(s.handleXferPut))
|
||||||
mux.Handle("POST /api/panel/fs/xfer/{xid}/cancel", s.requireAdmin(s.handleXferCancel))
|
mux.Handle("POST /api/panel/fs/xfer/{xid}/cancel", s.requireAdmin(s.handleXferCancel))
|
||||||
mux.Handle("GET /api/panel/fs/xfer/{xid}/result", s.requireAdmin(s.handleXferResult))
|
mux.Handle("GET /api/panel/fs/xfer/{xid}/result", s.requireAdmin(s.handleXferResult))
|
||||||
|
mux.Handle("POST /api/panel/agents/{id}/fs/ship", s.requireAdmin(s.handleFSShip))
|
||||||
|
mux.Handle("GET /api/panel/fs/job/{jid}", s.requireAdmin(s.handleShipJob))
|
||||||
|
mux.Handle("POST /api/panel/fs/job/{jid}/cancel", s.requireAdmin(s.handleShipCancel))
|
||||||
mux.Handle("POST /api/panel/agents/{id}/fs/{op}", s.requireAdmin(s.handleFSOp))
|
mux.Handle("POST /api/panel/agents/{id}/fs/{op}", s.requireAdmin(s.handleFSOp))
|
||||||
|
|
||||||
// Machine status (admin).
|
// Machine status (admin).
|
||||||
@@ -255,6 +296,7 @@ func (s *Server) Handler() http.Handler {
|
|||||||
// Agent API (token/secret auth inside handlers).
|
// Agent API (token/secret auth inside handlers).
|
||||||
mux.HandleFunc("POST /api/agent/enroll", s.handleEnroll)
|
mux.HandleFunc("POST /api/agent/enroll", s.handleEnroll)
|
||||||
mux.HandleFunc("GET /api/agent/poll", s.handlePoll)
|
mux.HandleFunc("GET /api/agent/poll", s.handlePoll)
|
||||||
|
mux.HandleFunc("GET /api/agent/ws", s.handleAgentWS)
|
||||||
mux.HandleFunc("GET /api/agent/config", s.handleAgentConfig)
|
mux.HandleFunc("GET /api/agent/config", s.handleAgentConfig)
|
||||||
mux.HandleFunc("GET /api/agent/term/{sid}/in", s.handleTermIn)
|
mux.HandleFunc("GET /api/agent/term/{sid}/in", s.handleTermIn)
|
||||||
mux.HandleFunc("POST /api/agent/term/{sid}/out", s.handleTermOut)
|
mux.HandleFunc("POST /api/agent/term/{sid}/out", s.handleTermOut)
|
||||||
@@ -266,6 +308,9 @@ func (s *Server) Handler() http.Handler {
|
|||||||
mux.HandleFunc("POST /api/agent/speed/relay/up", s.handleSpeedRelayUp)
|
mux.HandleFunc("POST /api/agent/speed/relay/up", s.handleSpeedRelayUp)
|
||||||
mux.HandleFunc("GET /api/agent/fs/xfer/{xid}/{i}", s.handleAgentXferGet)
|
mux.HandleFunc("GET /api/agent/fs/xfer/{xid}/{i}", s.handleAgentXferGet)
|
||||||
mux.HandleFunc("POST /api/agent/fs/xfer/{xid}/{i}", s.handleAgentXferPost)
|
mux.HandleFunc("POST /api/agent/fs/xfer/{xid}/{i}", s.handleAgentXferPost)
|
||||||
|
mux.HandleFunc("POST /api/agent/fs/job/{jid}/progress", s.handleShipProgress)
|
||||||
|
mux.HandleFunc("POST /api/agent/fs/relay/{rid}/{i}", s.handleRelayPost)
|
||||||
|
mux.HandleFunc("GET /api/agent/fs/relay/{rid}/{i}", s.handleRelayGet)
|
||||||
|
|
||||||
return s.logRequests(mux)
|
return s.logRequests(mux)
|
||||||
}
|
}
|
||||||
@@ -282,6 +327,36 @@ func (s *Server) requireAdmin(h http.HandlerFunc) http.Handler {
|
|||||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// A user who is not an admin uses only the machines given to them:
|
||||||
|
// any other is as if it were not there.
|
||||||
|
if !admin.IsAdmin() {
|
||||||
|
p := r.URL.Path
|
||||||
|
if strings.HasPrefix(p, "/agents/") || strings.HasPrefix(p, "/api/panel/agents/") || strings.HasPrefix(p, "/api/panel/term/") {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
if id == "" {
|
||||||
|
id = r.PathValue("agentID")
|
||||||
|
}
|
||||||
|
if id != "" && id != "presence" && !s.db.CanUseAgent(admin, id) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminsOnly lets only admins through: the settings, users, the firewall.
|
||||||
|
func (s *Server) adminsOnly(h http.HandlerFunc) http.Handler {
|
||||||
|
return s.requireAdmin(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.currentAdmin(r).IsAdmin() {
|
||||||
|
if r.Method == http.MethodGet && !strings.HasPrefix(r.URL.Path, "/api/") {
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, "только для администраторов", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
h(w, r)
|
h(w, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -336,32 +411,38 @@ func clientIP(r *http.Request) string {
|
|||||||
return host
|
return host
|
||||||
}
|
}
|
||||||
|
|
||||||
// staticHandler serves the embedded assets with a content hash as ETag. The
|
// assetHashes hashes every static file, for asset URLs and ETags.
|
||||||
// browser asks each time whether a file changed (no-cache) and gets a 304
|
func assetHashes(fsys fs.FS) map[string]string {
|
||||||
// when it did not, so a new release reaches it at once without reloading
|
out := map[string]string{}
|
||||||
// what stayed the same.
|
_ = fs.WalkDir(fsys, ".", func(p string, d fs.DirEntry, err error) error {
|
||||||
func staticHandler(fsys fs.FS) http.Handler {
|
if err != nil || d.IsDir() {
|
||||||
|
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))
|
files := http.FileServer(http.FS(fsys))
|
||||||
var mu sync.Mutex
|
|
||||||
etags := map[string]string{}
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
name := strings.TrimPrefix(r.URL.Path, "/")
|
h := hashes[strings.TrimPrefix(r.URL.Path, "/")]
|
||||||
mu.Lock()
|
if h != "" {
|
||||||
tag, ok := etags[name]
|
w.Header().Set("ETag", `"`+h+`"`)
|
||||||
mu.Unlock()
|
|
||||||
if !ok {
|
|
||||||
if data, err := fs.ReadFile(fsys, name); err == nil {
|
|
||||||
sum := sha256.Sum256(data)
|
|
||||||
tag = `"` + hex.EncodeToString(sum[:8]) + `"`
|
|
||||||
}
|
|
||||||
mu.Lock()
|
|
||||||
etags[name] = tag
|
|
||||||
mu.Unlock()
|
|
||||||
}
|
}
|
||||||
if tag != "" {
|
if v := r.URL.Query().Get("v"); h != "" && v == h {
|
||||||
w.Header().Set("ETag", tag)
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||||
|
} else {
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
}
|
}
|
||||||
w.Header().Set("Cache-Control", "no-cache")
|
|
||||||
files.ServeHTTP(w, r)
|
files.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,418 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sending a file or folder to another machine. The sending agent packs it as
|
||||||
|
// a tar stream; to another agent the stream goes in pieces through a relay
|
||||||
|
// here, a few pieces held at most, and the receiving agent unpacks it as it
|
||||||
|
// comes. To an SSH host the sending agent writes it there itself. The panel
|
||||||
|
// follows a job: how far the sender is, and the result.
|
||||||
|
|
||||||
|
// relay carries one stream between two agents, in numbered pieces.
|
||||||
|
type relay struct {
|
||||||
|
id, from, to string
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
changed chan struct{}
|
||||||
|
chunks map[int][]byte
|
||||||
|
low int // pieces below it were taken
|
||||||
|
end int // the last piece's number, -1 until it came
|
||||||
|
err string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *relay) notify() {
|
||||||
|
close(x.changed)
|
||||||
|
x.changed = make(chan struct{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// await waits until ready() holds and returns true with x.mu held.
|
||||||
|
func (x *relay) await(ctx context.Context, d time.Duration, ready func() bool) bool {
|
||||||
|
t := time.NewTimer(d)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
x.mu.Lock()
|
||||||
|
if ready() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
ch := x.changed
|
||||||
|
x.mu.Unlock()
|
||||||
|
select {
|
||||||
|
case <-ch:
|
||||||
|
case <-t.C:
|
||||||
|
return false
|
||||||
|
case <-ctx.Done():
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *relay) fail(msg string) {
|
||||||
|
x.mu.Lock()
|
||||||
|
if x.err == "" {
|
||||||
|
x.err = msg
|
||||||
|
x.chunks = nil
|
||||||
|
x.notify()
|
||||||
|
}
|
||||||
|
x.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
type shipJob struct {
|
||||||
|
id string
|
||||||
|
mu sync.Mutex
|
||||||
|
changed chan struct{}
|
||||||
|
ver int
|
||||||
|
prog proto.ShipProgress
|
||||||
|
done bool
|
||||||
|
res proto.TaskResult
|
||||||
|
cancel context.CancelFunc
|
||||||
|
stopped bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *shipJob) update(fn func()) {
|
||||||
|
j.mu.Lock()
|
||||||
|
fn()
|
||||||
|
j.ver++
|
||||||
|
close(j.changed)
|
||||||
|
j.changed = make(chan struct{})
|
||||||
|
j.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
type shipping struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
relays map[string]*relay
|
||||||
|
jobs map[string]*shipJob
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *shipping) relay(id string) *relay {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
return c.relays[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *shipping) job(id string) *shipJob {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
return c.jobs[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
// shipReq is what the panel asks for: Src on this agent to Dir/Name on agent
|
||||||
|
// To, or on the SSH host.
|
||||||
|
type shipReq struct {
|
||||||
|
Src string `json:"src"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Dir string `json:"dir"`
|
||||||
|
Conflict string `json:"conflict"`
|
||||||
|
Move bool `json:"move"`
|
||||||
|
To string `json:"to"`
|
||||||
|
SSH *proto.SSHTarget `json:"ssh"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/panel/agents/{id}/fs/ship - start sending; the answer names the
|
||||||
|
// job to follow.
|
||||||
|
func (s *Server) handleFSShip(w http.ResponseWriter, r *http.Request) {
|
||||||
|
from := r.PathValue("id")
|
||||||
|
var q shipReq
|
||||||
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&q); err != nil {
|
||||||
|
writeJSON(w, proto.TaskResult{Error: "bad request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q.Name, q.Dir = strings.TrimSpace(q.Name), strings.TrimSpace(q.Dir)
|
||||||
|
if q.Src == "" || q.Name == "" || strings.Contains(q.Name, "/") || q.Dir == "" {
|
||||||
|
writeJSON(w, proto.TaskResult{Error: "не указано, что и куда"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.fsAgent(w, from, true) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
where := ""
|
||||||
|
if q.SSH != nil {
|
||||||
|
t := q.SSH
|
||||||
|
t.Host, t.User = strings.TrimSpace(t.Host), strings.TrimSpace(t.User)
|
||||||
|
if t.Port == 0 {
|
||||||
|
t.Port = 22
|
||||||
|
}
|
||||||
|
if t.Host == "" || t.User == "" || t.Port < 1 || t.Port > 65535 {
|
||||||
|
writeJSON(w, proto.TaskResult{Error: "нужны хост, порт и пользователь"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
where = "ssh:" + t.User + "@" + t.Host + ":" + strconv.Itoa(t.Port)
|
||||||
|
} else {
|
||||||
|
if q.To == "" {
|
||||||
|
writeJSON(w, proto.TaskResult{Error: "не выбрана машина"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.db.CanUseAgent(s.currentAdmin(r), q.To) {
|
||||||
|
writeJSON(w, proto.TaskResult{Error: "нет доступа к той машине"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.fsAgent(w, q.To, true) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
where = "agent:" + q.To
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), xferTimeout)
|
||||||
|
j := &shipJob{id: randToken()[:24], changed: make(chan struct{}), cancel: cancel}
|
||||||
|
s.ship.mu.Lock()
|
||||||
|
if s.ship.jobs == nil {
|
||||||
|
s.ship.jobs, s.ship.relays = map[string]*shipJob{}, map[string]*relay{}
|
||||||
|
}
|
||||||
|
s.ship.jobs[j.id] = j
|
||||||
|
s.ship.mu.Unlock()
|
||||||
|
if admin := s.currentAdmin(r); admin != nil {
|
||||||
|
op := "copy"
|
||||||
|
if q.Move {
|
||||||
|
op = "move"
|
||||||
|
}
|
||||||
|
s.db.Audit(admin.ID, from, "fs.ship."+op+":"+q.Src+" -> "+where+":"+strings.TrimSuffix(q.Dir, "/")+"/"+q.Name)
|
||||||
|
}
|
||||||
|
go s.runShip(ctx, j, from, q)
|
||||||
|
data, _ := json.Marshal(map[string]string{"job": j.id})
|
||||||
|
writeJSON(w, proto.TaskResult{OK: true, Data: data})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) runShip(ctx context.Context, j *shipJob, from string, q shipReq) {
|
||||||
|
defer j.cancel()
|
||||||
|
res := s.ship1(ctx, j, from, q)
|
||||||
|
if !res.OK && j.stopped {
|
||||||
|
res.Error = "отменено"
|
||||||
|
}
|
||||||
|
j.update(func() { j.done, j.res = true, res })
|
||||||
|
// The result waits a while for the panel to come for it.
|
||||||
|
time.AfterFunc(xferIdle, func() {
|
||||||
|
s.ship.mu.Lock()
|
||||||
|
delete(s.ship.jobs, j.id)
|
||||||
|
s.ship.mu.Unlock()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func oldAgent(res proto.TaskResult) proto.TaskResult {
|
||||||
|
if unknownOp(res) {
|
||||||
|
res.Error = "агент не умеет отправлять на другие машины - обновите его"
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) ship1(ctx context.Context, j *shipJob, from string, q shipReq) proto.TaskResult {
|
||||||
|
args := proto.FSShipArgs{Src: q.Src, Name: q.Name, Dir: q.Dir, Conflict: q.Conflict, Job: j.id}
|
||||||
|
if q.SSH != nil {
|
||||||
|
args.SSH, args.Move = q.SSH, q.Move
|
||||||
|
res, err := s.waitTask(ctx, from, proto.TaskFSShipOut, args, xferTimeout)
|
||||||
|
if err != nil {
|
||||||
|
return proto.TaskResult{Error: err.Error()}
|
||||||
|
}
|
||||||
|
return oldAgent(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
x := &relay{id: randToken()[:24], from: from, to: q.To, changed: make(chan struct{}), chunks: map[int][]byte{}, end: -1}
|
||||||
|
s.ship.mu.Lock()
|
||||||
|
s.ship.relays[x.id] = x
|
||||||
|
s.ship.mu.Unlock()
|
||||||
|
defer func() {
|
||||||
|
s.ship.mu.Lock()
|
||||||
|
delete(s.ship.relays, x.id)
|
||||||
|
s.ship.mu.Unlock()
|
||||||
|
}()
|
||||||
|
go func() { <-ctx.Done(); x.fail("отменено") }()
|
||||||
|
args.Relay, args.Chunk = x.id, xferChunk
|
||||||
|
|
||||||
|
var in proto.TaskResult
|
||||||
|
inDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(inDone)
|
||||||
|
res, err := s.waitTask(ctx, q.To, proto.TaskFSShipIn, args, xferTimeout)
|
||||||
|
if err != nil {
|
||||||
|
res = proto.TaskResult{Error: err.Error()}
|
||||||
|
}
|
||||||
|
in = oldAgent(res)
|
||||||
|
if !in.OK {
|
||||||
|
x.fail(in.Error)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
out, err := s.waitTask(ctx, from, proto.TaskFSShipOut, args, xferTimeout)
|
||||||
|
if err != nil {
|
||||||
|
out = proto.TaskResult{Error: err.Error()}
|
||||||
|
}
|
||||||
|
out = oldAgent(out)
|
||||||
|
if !out.OK {
|
||||||
|
x.fail(out.Error)
|
||||||
|
}
|
||||||
|
<-inDone
|
||||||
|
if !in.OK {
|
||||||
|
return proto.TaskResult{Error: "на той машине: " + in.Error}
|
||||||
|
}
|
||||||
|
if !out.OK {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
if q.Move {
|
||||||
|
res, err := s.waitTask(ctx, from, proto.TaskFSDelTree, proto.FSPathArgs{Path: q.Src}, fsTaskTimeout)
|
||||||
|
if err == nil && !res.OK {
|
||||||
|
err = errText(res.Error)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return proto.TaskResult{Error: "отправлено, но здесь не удалилось: " + err.Error()}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The receiver's count: what it put down and what it kept as it was.
|
||||||
|
return in
|
||||||
|
}
|
||||||
|
|
||||||
|
type errText string
|
||||||
|
|
||||||
|
func (e errText) Error() string { return string(e) }
|
||||||
|
|
||||||
|
// GET /api/panel/fs/job/{jid}?v=N - the job's state once it differs from
|
||||||
|
// version N, or after a while anyway.
|
||||||
|
func (s *Server) handleShipJob(w http.ResponseWriter, r *http.Request) {
|
||||||
|
j := s.ship.job(r.PathValue("jid"))
|
||||||
|
if j == nil {
|
||||||
|
writeJSON(w, map[string]any{"done": true, "result": proto.TaskResult{Error: "задание не найдено"}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen, _ := strconv.Atoi(r.URL.Query().Get("v"))
|
||||||
|
t := time.NewTimer(25 * time.Second)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
j.mu.Lock()
|
||||||
|
if j.ver != seen || j.done {
|
||||||
|
out := map[string]any{"v": j.ver, "progress": j.prog, "done": j.done}
|
||||||
|
if j.done {
|
||||||
|
out["result"] = j.res
|
||||||
|
}
|
||||||
|
j.mu.Unlock()
|
||||||
|
writeJSON(w, out)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ch := j.changed
|
||||||
|
j.mu.Unlock()
|
||||||
|
select {
|
||||||
|
case <-ch:
|
||||||
|
case <-t.C:
|
||||||
|
seen = -1 // answer with what there is
|
||||||
|
case <-r.Context().Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/panel/fs/job/{jid}/cancel
|
||||||
|
func (s *Server) handleShipCancel(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if j := s.ship.job(r.PathValue("jid")); j != nil {
|
||||||
|
j.update(func() { j.stopped = true })
|
||||||
|
j.cancel()
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/agent/fs/job/{jid}/progress - the sender's progress. 410: the
|
||||||
|
// job is over or cancelled, stop.
|
||||||
|
func (s *Server) handleShipProgress(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.authenticateAgent(r) == nil {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
j := s.ship.job(r.PathValue("jid"))
|
||||||
|
var p proto.ShipProgress
|
||||||
|
if j == nil || json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&p) != nil {
|
||||||
|
http.Error(w, "gone", http.StatusGone)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stop := false
|
||||||
|
j.update(func() { j.prog, stop = p, j.stopped || j.done })
|
||||||
|
if stop {
|
||||||
|
http.Error(w, "gone", http.StatusGone)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/agent/fs/relay/{rid}/{i} - piece i of the stream from the
|
||||||
|
// sender, X-Last on the last. 503: no room yet, again; 410: it is over.
|
||||||
|
func (s *Server) handleRelayPost(w http.ResponseWriter, r *http.Request) {
|
||||||
|
a := s.authenticateAgent(r)
|
||||||
|
if a == nil {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
x := s.ship.relay(r.PathValue("rid"))
|
||||||
|
i, err := strconv.Atoi(r.PathValue("i"))
|
||||||
|
if x == nil || x.from != a.ID || err != nil || i < 0 {
|
||||||
|
http.Error(w, "передача не найдена", http.StatusGone)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := readPiece(w, r)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "кусок не дошёл", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !x.await(r.Context(), xferWait, func() bool { return x.err != "" || i < x.low+xferBuffer }) {
|
||||||
|
http.Error(w, "нет места, повторите", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer x.mu.Unlock()
|
||||||
|
if x.err != "" {
|
||||||
|
http.Error(w, x.err, http.StatusGone)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if i >= x.low && x.chunks[i] == nil {
|
||||||
|
x.chunks[i] = data
|
||||||
|
if r.Header.Get("X-Last") != "" {
|
||||||
|
x.end = i
|
||||||
|
}
|
||||||
|
x.notify()
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/agent/fs/relay/{rid}/{i} - piece i for the receiver, X-Last on
|
||||||
|
// the last. Asking for i says every piece below it is written, so those are
|
||||||
|
// let go. 503: not here yet, ask again; 410: it is over.
|
||||||
|
func (s *Server) handleRelayGet(w http.ResponseWriter, r *http.Request) {
|
||||||
|
a := s.authenticateAgent(r)
|
||||||
|
if a == nil {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
x := s.ship.relay(r.PathValue("rid"))
|
||||||
|
i, err := strconv.Atoi(r.PathValue("i"))
|
||||||
|
if x == nil || x.to != a.ID || err != nil || i < 0 {
|
||||||
|
http.Error(w, "передача не найдена", http.StatusGone)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
x.mu.Lock()
|
||||||
|
if i > x.low {
|
||||||
|
for k := x.low; k < i; k++ {
|
||||||
|
delete(x.chunks, k)
|
||||||
|
}
|
||||||
|
x.low = i
|
||||||
|
x.notify()
|
||||||
|
}
|
||||||
|
x.mu.Unlock()
|
||||||
|
if !x.await(r.Context(), xferWait, func() bool { return x.err != "" || x.chunks[i] != nil }) {
|
||||||
|
http.Error(w, "кусок ещё не пришёл", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if x.err != "" {
|
||||||
|
msg := x.err
|
||||||
|
x.mu.Unlock()
|
||||||
|
http.Error(w, msg, http.StatusGone)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, last := x.chunks[i], i == x.end
|
||||||
|
x.mu.Unlock()
|
||||||
|
w.Header().Set("Content-Type", "application/octet-stream")
|
||||||
|
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
|
||||||
|
if last {
|
||||||
|
w.Header().Set("X-Last", "1")
|
||||||
|
}
|
||||||
|
_, _ = w.Write(data)
|
||||||
|
}
|
||||||
@@ -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
|
set -eu
|
||||||
|
|
||||||
HOST="${HOST:-{{.Host}}}"
|
HOST="${HOST:-{{.Host}}}"
|
||||||
|
# The server's other address (the direct one, not behind a proxy like
|
||||||
|
# Cloudflare, or the main one when this script came from the direct one): the
|
||||||
|
# agent turns to it when HOST is out of reach.
|
||||||
|
FALLBACK_HOST="${FALLBACK_HOST:-{{.Fallback}}}"
|
||||||
{{if .Secret}}ENROLL_SECRET="${ENROLL_SECRET:-{{.Secret}}}"
|
{{if .Secret}}ENROLL_SECRET="${ENROLL_SECRET:-{{.Secret}}}"
|
||||||
{{end}}
|
{{end}}
|
||||||
BIN_PATH="/usr/local/bin/lpagent"
|
BIN_PATH="/usr/local/bin/lpagent"
|
||||||
@@ -108,9 +112,9 @@ fi
|
|||||||
# --- downloader -------------------------------------------------------------
|
# --- downloader -------------------------------------------------------------
|
||||||
download() { # download URL DEST
|
download() { # download URL DEST
|
||||||
if command -v curl >/dev/null 2>&1; then
|
if command -v curl >/dev/null 2>&1; then
|
||||||
curl -fSL -o "$2" "$1"
|
curl -fSL --connect-timeout 15 -o "$2" "$1"
|
||||||
elif command -v wget >/dev/null 2>&1; then
|
elif command -v wget >/dev/null 2>&1; then
|
||||||
wget -qO "$2" "$1"
|
wget -q -T 30 -O "$2" "$1"
|
||||||
else
|
else
|
||||||
red "Нужен curl или wget."; exit 1
|
red "Нужен curl или wget."; exit 1
|
||||||
fi
|
fi
|
||||||
@@ -118,7 +122,14 @@ download() { # download URL DEST
|
|||||||
|
|
||||||
bold "Скачивание агента ($ARCH) с $HOST…"
|
bold "Скачивание агента ($ARCH) с $HOST…"
|
||||||
TMP="$(mktemp)"
|
TMP="$(mktemp)"
|
||||||
download "$HOST/download/agent-linux-$ARCH" "$TMP"
|
if ! download "$HOST/download/agent-linux-$ARCH" "$TMP"; then
|
||||||
|
if [ -n "$FALLBACK_HOST" ] && [ "$FALLBACK_HOST" != "$HOST" ]; then
|
||||||
|
bold "$HOST недоступен, скачивание с $FALLBACK_HOST…"
|
||||||
|
download "$FALLBACK_HOST/download/agent-linux-$ARCH" "$TMP"
|
||||||
|
else
|
||||||
|
red "Не удалось скачать агента."; exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
install -m 0755 "$TMP" "$BIN_PATH"
|
install -m 0755 "$TMP" "$BIN_PATH"
|
||||||
rm -f "$TMP"
|
rm -f "$TMP"
|
||||||
green "Бинарник установлен: $BIN_PATH"
|
green "Бинарник установлен: $BIN_PATH"
|
||||||
@@ -129,6 +140,7 @@ chmod 700 "$CONF_DIR"
|
|||||||
umask 077
|
umask 077
|
||||||
cat > "$CONF_FILE" <<EOF
|
cat > "$CONF_FILE" <<EOF
|
||||||
HOST=$HOST
|
HOST=$HOST
|
||||||
|
FALLBACK_HOST=$FALLBACK_HOST
|
||||||
ENROLL_SECRET=${ENROLL_SECRET:-}
|
ENROLL_SECRET=${ENROLL_SECRET:-}
|
||||||
AGENT_NAME=$AGENT_NAME
|
AGENT_NAME=$AGENT_NAME
|
||||||
STATE_PATH=$STATE_FILE
|
STATE_PATH=$STATE_FILE
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
// The account button in the top bar: a dialog with who is signed in, their
|
||||||
|
// passkeys (add one, remove one), the password switch and signing out. The
|
||||||
|
// password can be turned off once there is a passkey to sign in with.
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
var btn = document.getElementById('acct');
|
||||||
|
if (!btn) return;
|
||||||
|
|
||||||
|
var b64u = {
|
||||||
|
enc: function (buf) {
|
||||||
|
var s = '', b = new Uint8Array(buf);
|
||||||
|
for (var i = 0; i < b.length; i++) s += String.fromCharCode(b[i]);
|
||||||
|
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||||
|
},
|
||||||
|
dec: function (str) {
|
||||||
|
var s = atob(str.replace(/-/g, '+').replace(/_/g, '/') + '==='.slice((str.length + 3) % 4));
|
||||||
|
var b = new Uint8Array(s.length);
|
||||||
|
for (var i = 0; i < s.length; i++) b[i] = s.charCodeAt(i);
|
||||||
|
return b.buffer;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
var esc = function (s) { return String(s).replace(/[&<>"]/g, function (c) { return {'&': '&', '<': '<', '>': '>', '"': '"'}[c]; }); };
|
||||||
|
var when = function (t) { return t ? new Date(t * 1000).toLocaleString() : 'ещё не использовался'; };
|
||||||
|
var csrf = (document.cookie.match(/(?:^|; )lpra_csrf=([^;]*)/) || [])[1] || '';
|
||||||
|
var X = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>';
|
||||||
|
var KEY = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="7.5" cy="15.5" r="4.5"/><path d="m21 2-9.6 9.6M15.5 7.5l3 3L22 7l-3-3"/></svg>';
|
||||||
|
|
||||||
|
var dlg = document.createElement('dialog');
|
||||||
|
dlg.id = 'acctdlg';
|
||||||
|
dlg.tabIndex = -1;
|
||||||
|
dlg.innerHTML =
|
||||||
|
'<div class="dlg-h"><strong style="flex:1">Аккаунт</strong>' +
|
||||||
|
'<button type="button" class="btn ghost small icon close x" data-close title="Закрыть" aria-label="Закрыть">' + X + '</button></div>' +
|
||||||
|
'<div class="dlg-b acct">' +
|
||||||
|
' <div class="acct-who"><span class="acct-ava"></span><div><div class="acct-name"></div><div class="muted acct-mode"></div></div></div>' +
|
||||||
|
' <h3>Ключи доступа</h3>' +
|
||||||
|
' <p class="muted acct-hint">Вход отпечатком, лицом, PIN-кодом устройства или аппаратным ключом. Ключей может быть несколько - например, телефон и ноутбук.</p>' +
|
||||||
|
' <ul class="acct-keys"></ul>' +
|
||||||
|
' <div class="acct-add"><input type="text" class="acct-keyname" maxlength="60" placeholder="название, например «телефон»">' +
|
||||||
|
' <button type="button" class="acct-addbtn">Добавить ключ</button></div>' +
|
||||||
|
' <p class="acct-msg"></p>' +
|
||||||
|
' <label class="acct-pw"><span><b>Вход по паролю</b><small class="muted acct-pwnote"></small></span>' +
|
||||||
|
' <input type="checkbox" class="sw" role="switch"></label>' +
|
||||||
|
' <details class="acct-chpw"><summary>Сменить пароль</summary>' +
|
||||||
|
' <form class="acct-pwform" autocomplete="on">' +
|
||||||
|
' <input type="text" name="username" autocomplete="username" hidden>' +
|
||||||
|
' <input type="password" name="current" placeholder="текущий пароль" autocomplete="current-password" required>' +
|
||||||
|
' <input type="password" name="new" placeholder="новый пароль, от 8 символов" autocomplete="new-password" minlength="8" required>' +
|
||||||
|
' <input type="password" name="again" placeholder="новый ещё раз" autocomplete="new-password" required>' +
|
||||||
|
' <button type="submit">Сменить</button></form></details>' +
|
||||||
|
' <details class="acct-pin"><summary>PIN-код <small class="acct-pinstate"></small></summary>' +
|
||||||
|
' <form class="acct-pinform">' +
|
||||||
|
' <input type="password" name="current" inputmode="numeric" maxlength="4" pattern="[0-9]{4}" placeholder="текущий PIN" autocomplete="off">' +
|
||||||
|
' <input type="password" name="pin" inputmode="numeric" maxlength="4" pattern="[0-9]{4}" placeholder="новый PIN, 4 цифры" autocomplete="off">' +
|
||||||
|
' <input type="password" name="again" inputmode="numeric" maxlength="4" pattern="[0-9]{4}" placeholder="новый ещё раз" autocomplete="off">' +
|
||||||
|
' <div class="row"><button type="button" class="btn danger acct-pindel">Убрать PIN</button><button type="submit" class="acct-pinsave">Сохранить</button></div>' +
|
||||||
|
' </form>' +
|
||||||
|
' <label class="acct-pingrace"><span>На этом устройстве не спрашивать после ввода</span><select>' +
|
||||||
|
' <option value="0">каждый раз</option><option value="5">5 минут</option><option value="15">15 минут</option>' +
|
||||||
|
' <option value="30">30 минут</option><option value="60">60 минут</option></select></label>' +
|
||||||
|
' <p class="acct-pinmsg" aria-live="polite"></p><p class="muted" style="font-size:12px;margin:8px 0 0">Спрашивается в каждой новой вкладке; при перезагрузке той же вкладки - нет.</p></details>' +
|
||||||
|
' <form method="post" action="/logout" class="acct-out"><input type="hidden" name="csrf"><button type="submit" class="btn ghost">Выйти</button></form>' +
|
||||||
|
'</div>';
|
||||||
|
document.body.appendChild(dlg);
|
||||||
|
dlg.querySelector('.acct-out [name=csrf]').value = decodeURIComponent(csrf);
|
||||||
|
var $ = function (sel) { return dlg.querySelector(sel); };
|
||||||
|
$('[data-close]').onclick = function () { dlg.close(); };
|
||||||
|
var supported = !!(window.PublicKeyCredential && navigator.credentials && window.isSecureContext);
|
||||||
|
|
||||||
|
function msg(text, bad) {
|
||||||
|
$('.acct-msg').textContent = text || '';
|
||||||
|
$('.acct-msg').className = 'acct-msg' + (bad ? ' bad' : '');
|
||||||
|
}
|
||||||
|
function load() {
|
||||||
|
return fetch('/api/panel/account', {cache: 'no-store'}).then(function (r) { return r.json(); }).then(function (d) {
|
||||||
|
$('.acct-name').textContent = d.username;
|
||||||
|
dlg.querySelector('.acct-pwform [name=username]').value = d.username;
|
||||||
|
const pf = dlg.querySelector('.acct-pinform');
|
||||||
|
dlg.querySelector('.acct-pinstate').textContent = d.pin ? '· установлен' : '· не задан';
|
||||||
|
pf.current.hidden = !d.pin;
|
||||||
|
pf.querySelector('.acct-pindel').hidden = !d.pin;
|
||||||
|
pf.querySelector('.acct-pinsave').textContent = d.pin ? 'Сменить' : 'Установить';
|
||||||
|
$('.acct-pingrace').hidden = !d.pin;
|
||||||
|
try { $('.acct-pingrace select').value = localStorage.getItem('lpra.pin.grace') || '0'; } catch (e) { /* default */ }
|
||||||
|
$('.acct-ava').textContent = (d.username || '?').slice(0, 1).toUpperCase();
|
||||||
|
$('.acct-mode').textContent = d.password_off ? 'вход только по ключу доступа' : d.keys.length ? 'вход по паролю или ключу' : 'вход по паролю';
|
||||||
|
const sw = $('.acct-pw .sw');
|
||||||
|
sw.checked = !d.password_off;
|
||||||
|
sw.disabled = !d.keys.length || d.pw_locked;
|
||||||
|
$('.acct-pwnote').textContent = d.pw_locked ? 'закреплено администратором' + (d.password_off ? ': только по ключу' : '')
|
||||||
|
: !d.keys.length ? 'выключить можно, когда есть хотя бы один ключ'
|
||||||
|
: d.password_off ? 'выключен: войти можно только ключом' : 'можно выключить и входить только ключом';
|
||||||
|
var ul = $('.acct-keys');
|
||||||
|
ul.innerHTML = d.keys.length ? '' : '<li class="muted">Ключей пока нет.</li>';
|
||||||
|
d.keys.forEach(function (k) {
|
||||||
|
var li = document.createElement('li');
|
||||||
|
li.innerHTML = '<span class="acct-kicon">' + KEY + '</span><div class="acct-kinfo"><div>' + esc(k.name) + '</div>' +
|
||||||
|
'<div class="muted">добавлен ' + esc(when(k.created)) + ' · вход: ' + esc(when(k.last_used)) + '</div></div>' +
|
||||||
|
'<button type="button" class="btn danger small" title="Удалить ключ">Удалить</button>';
|
||||||
|
li.querySelector('button').onclick = function () {
|
||||||
|
var last = d.keys.length === 1;
|
||||||
|
if (!confirm('Удалить ключ «' + k.name + '»?' + (last && d.password_off ? ' Это последний ключ: вход по паролю снова включится.' : ''))) return;
|
||||||
|
fetch('/api/panel/account/keys/' + k.id + '/delete', {method: 'POST'}).then(function (r) { return r.json(); })
|
||||||
|
.then(function (r) { if (!r.ok) msg(r.error, true); else { msg('ключ удалён'); load(); } });
|
||||||
|
};
|
||||||
|
ul.appendChild(li);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
$('.acct-addbtn').onclick = function () {
|
||||||
|
if (!supported) { msg('этот браузер не умеет ключи доступа, или страница открыта не по https', true); return; }
|
||||||
|
var add = $('.acct-addbtn');
|
||||||
|
add.disabled = true;
|
||||||
|
msg('подтвердите на устройстве…');
|
||||||
|
fetch('/api/panel/account/keys/begin', {method: 'POST'}).then(function (r) { return r.json(); }).then(function (r) {
|
||||||
|
var o = r.options;
|
||||||
|
o.challenge = b64u.dec(o.challenge);
|
||||||
|
o.user.id = b64u.dec(o.user.id);
|
||||||
|
o.excludeCredentials.forEach(function (c) { c.id = b64u.dec(c.id); });
|
||||||
|
return navigator.credentials.create({publicKey: o});
|
||||||
|
}).then(function (cred) {
|
||||||
|
return fetch('/api/panel/account/keys/finish', {
|
||||||
|
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: $('.acct-keyname').value,
|
||||||
|
clientDataJSON: b64u.enc(cred.response.clientDataJSON),
|
||||||
|
attestationObject: b64u.enc(cred.response.attestationObject)
|
||||||
|
})
|
||||||
|
}).then(function (r) { return r.json(); });
|
||||||
|
}).then(function (r) {
|
||||||
|
if (!r.ok) { msg(r.error, true); return; }
|
||||||
|
$('.acct-keyname').value = '';
|
||||||
|
msg('ключ добавлен - теперь вход по нему');
|
||||||
|
load();
|
||||||
|
}).catch(function (e) {
|
||||||
|
msg(e && e.name === 'NotAllowedError' ? 'отменено' : e && e.name === 'InvalidStateError' ? 'этот ключ уже добавлен' : 'не вышло: ' + (e && e.message || e), true);
|
||||||
|
}).then(function () { add.disabled = false; });
|
||||||
|
};
|
||||||
|
$('.acct-pwform').onsubmit = function (ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
const f = ev.target;
|
||||||
|
if (f.new.value !== f.again.value) { msg('новые пароли не совпадают', true); return; }
|
||||||
|
f.querySelector('button').disabled = true;
|
||||||
|
fetch('/api/panel/account/password/change', {method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({current: f.current.value, new: f.new.value})})
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (r) {
|
||||||
|
if (!r.ok) { msg(r.error, true); return; }
|
||||||
|
f.reset();
|
||||||
|
dlg.querySelector('.acct-chpw').open = false;
|
||||||
|
msg('пароль сменён');
|
||||||
|
})
|
||||||
|
.catch(function () { msg('ошибка сети', true); })
|
||||||
|
.then(function () { f.querySelector('button').disabled = false; });
|
||||||
|
};
|
||||||
|
function pinMsg(text, bad) {
|
||||||
|
$('.acct-pinmsg').textContent = text || '';
|
||||||
|
$('.acct-pinmsg').className = 'acct-pinmsg' + (bad ? ' bad' : '');
|
||||||
|
}
|
||||||
|
function savePin(pin) {
|
||||||
|
const f = $('.acct-pinform');
|
||||||
|
return fetch('/api/panel/account/pin', {method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({pin: pin, current: f.current.value})})
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (r) {
|
||||||
|
if (!r.ok) { pinMsg(r.error, true); return; }
|
||||||
|
f.reset();
|
||||||
|
// This tab stays unlocked: the PIN is for tabs opened from now on.
|
||||||
|
try { sessionStorage.setItem('lpra.pin.ok', '1'); } catch (e) { /* asked next load */ }
|
||||||
|
pinMsg(pin ? 'PIN сохранён' : 'PIN убран');
|
||||||
|
load();
|
||||||
|
})
|
||||||
|
.catch(function () { pinMsg('ошибка сети', true); });
|
||||||
|
}
|
||||||
|
$('.acct-pinform').onsubmit = function (ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
const f = ev.target;
|
||||||
|
if (!/^[0-9]{4}$/.test(f.pin.value)) { pinMsg('PIN - ровно 4 цифры', true); return; }
|
||||||
|
if (f.pin.value !== f.again.value) { pinMsg('новые PIN не совпадают', true); return; }
|
||||||
|
savePin(f.pin.value);
|
||||||
|
};
|
||||||
|
$('.acct-pingrace select').onchange = function (ev) {
|
||||||
|
const min = Number(ev.target.value);
|
||||||
|
try {
|
||||||
|
localStorage.setItem('lpra.pin.grace', String(min));
|
||||||
|
// Counted from now, as the PIN was entered to get here.
|
||||||
|
localStorage.setItem('lpra.pin.until', min ? String(Date.now() + min * 60000) : '0');
|
||||||
|
} catch (e) { pinMsg('браузер не даёт сохранить настройку', true); return; }
|
||||||
|
pinMsg(min ? 'на этом устройстве PIN не спросят ' + min + ' мин после ввода' : 'PIN спрашивается в каждой новой вкладке');
|
||||||
|
};
|
||||||
|
$('.acct-pindel').onclick = function () {
|
||||||
|
if (confirm('Убрать PIN-код?')) savePin('');
|
||||||
|
};
|
||||||
|
$('.acct-pw .sw').onchange = function (ev) {
|
||||||
|
const off = !ev.target.checked;
|
||||||
|
if (off && !confirm('Выключить вход по паролю? Войти можно будет только ключом доступа. Если ключи потеряются, их сможет сбросить другой администратор в «Настройках».')) {
|
||||||
|
ev.target.checked = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetch('/api/panel/account/password', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({off: off})})
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (r) { if (!r.ok) msg(r.error, true); else msg(off ? 'вход по паролю выключен' : 'вход по паролю включён'); load(); });
|
||||||
|
};
|
||||||
|
btn.onclick = function () {
|
||||||
|
msg('');
|
||||||
|
pinMsg('');
|
||||||
|
load().then(function () { dlg.showModal(); dlg.focus(); });
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -25,6 +25,13 @@ header.topbar {
|
|||||||
padding: 14px 24px; border-bottom: 1px solid var(--border); background: var(--panel);
|
padding: 14px 24px; border-bottom: 1px solid var(--border); background: var(--panel);
|
||||||
}
|
}
|
||||||
header.topbar .brand { font-weight: 600; }
|
header.topbar .brand { font-weight: 600; }
|
||||||
|
header.topbar .brand a.home { color: inherit; text-decoration: none; transition: color .15s; }
|
||||||
|
header.topbar .brand a.home:hover { color: var(--accent); }
|
||||||
|
/* A long machine name gives way: the links and the status stay in sight. */
|
||||||
|
@media (min-width: 641px) {
|
||||||
|
header.topbar .brand { flex: 1 1 auto; min-width: 0; margin-right: 16px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
header.topbar nav { flex: none; white-space: nowrap; }
|
||||||
|
}
|
||||||
header.topbar nav a { margin-left: 18px; color: var(--muted); }
|
header.topbar nav a { margin-left: 18px; color: var(--muted); }
|
||||||
header.topbar nav a:hover { color: var(--text); }
|
header.topbar nav a:hover { color: var(--text); }
|
||||||
.wrap { max-width: 960px; margin: 0 auto; padding: 24px 16px; }
|
.wrap { max-width: 960px; margin: 0 auto; padding: 24px 16px; }
|
||||||
@@ -102,6 +109,112 @@ pre { padding: 12px; overflow-x: auto; font-size: 13px; white-space: pre-wrap; w
|
|||||||
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||||
.spacer { flex: 1; }
|
.spacer { flex: 1; }
|
||||||
#terminal { width: 100%; height: calc(100vh - 150px); }
|
#terminal { width: 100%; height: calc(100vh - 150px); }
|
||||||
|
/* The PIN lock of a new tab: the page stays hidden until it is unlocked. */
|
||||||
|
html.pin-wait body > :not(.pinlock) { visibility: hidden; }
|
||||||
|
.pinlock { position: fixed; inset: 0; z-index: 1000; display: flex; align-items: center; justify-content: center; padding: 16px;
|
||||||
|
background: rgba(10, 13, 17, .92); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
|
||||||
|
opacity: 0; transition: opacity .3s ease; }
|
||||||
|
.pinlock.on { opacity: 1; }
|
||||||
|
.pinlock.done { opacity: 0; pointer-events: none; }
|
||||||
|
.pin-card { width: 100%; max-width: 300px; display: flex; flex-direction: column; align-items: center; text-align: center;
|
||||||
|
transform: translateY(8px) scale(.98); transition: transform .3s ease; }
|
||||||
|
.pinlock.on .pin-card { transform: none; }
|
||||||
|
.pinlock.done .pin-card { transform: scale(1.03); }
|
||||||
|
.pin-ico { width: 54px; height: 54px; border-radius: 50%; display: flex; align-items: center; justify-content: center;
|
||||||
|
background: #12202f; border: 1px solid var(--border); color: var(--accent); }
|
||||||
|
.pin-title { margin: 14px 0 18px; font-size: 18px; font-weight: 600; }
|
||||||
|
.pin-dots { display: flex; gap: 16px; }
|
||||||
|
.pin-dots i { width: 14px; height: 14px; border-radius: 50%; border: 2px solid var(--muted); transition: background .15s, border-color .15s, transform .15s; }
|
||||||
|
.pin-dots i.on { background: var(--accent); border-color: var(--accent); transform: scale(1.1); }
|
||||||
|
.pin-card.bad .pin-dots i.on { background: var(--danger); border-color: var(--danger); }
|
||||||
|
.pin-card.bad .pin-dots { animation: pin-shake .38s ease; }
|
||||||
|
@keyframes pin-shake { 20% { transform: translateX(-9px); } 40% { transform: translateX(8px); } 60% { transform: translateX(-5px); } 80% { transform: translateX(3px); } }
|
||||||
|
.pin-msg { min-height: 20px; margin: 14px 0 6px; font-size: 13px; color: var(--danger); }
|
||||||
|
.pin-pad { display: grid; grid-template-columns: repeat(3, 72px); gap: 14px; justify-content: center; }
|
||||||
|
.pin-pad button { width: 72px; height: 72px; border-radius: 50%; padding: 0; display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
background: #151a21; border: 1px solid var(--border); color: var(--text); font-size: 26px; font-weight: 400;
|
||||||
|
-webkit-tap-highlight-color: transparent; touch-action: manipulation; transition: background .12s, transform .08s; }
|
||||||
|
.pin-pad button:hover { background: #1b222c; }
|
||||||
|
.pin-pad button:active { background: #12202f; transform: scale(.94); }
|
||||||
|
.pin-pad button[data-k="back"], .pin-pad button[data-k="out"] { background: transparent; border-color: transparent; color: #c26a6a; }
|
||||||
|
.pin-pad button[data-k="out"] { font-size: 15px; font-weight: 500; }
|
||||||
|
.pin-pad button[data-k="back"]:hover, .pin-pad button[data-k="out"]:hover { background: rgba(194, 106, 106, .1); }
|
||||||
|
.pin-pad button[data-k="back"]:active, .pin-pad button[data-k="out"]:active { background: rgba(194, 106, 106, .18); }
|
||||||
|
@media (max-height: 560px) { .pin-pad { grid-template-columns: repeat(3, 58px); gap: 10px; } .pin-pad button { width: 58px; height: 58px; font-size: 22px; } .pin-pad button[data-k="out"] { font-size: 13px; } }
|
||||||
|
|
||||||
|
/* Dialogs shared by the pages. */
|
||||||
|
dialog { background: var(--panel); color: var(--text); border: 1px solid var(--border);
|
||||||
|
border-radius: 12px; padding: 0; max-width: 900px; width: 92vw; }
|
||||||
|
dialog::backdrop { background: rgba(0, 0, 0, .6); }
|
||||||
|
dialog:focus { outline: none; }
|
||||||
|
/* Dialogs come and go smoothly: rise and fade in, sink and fade out, the
|
||||||
|
backdrop with them. Browsers without the exit transition just close. */
|
||||||
|
dialog { opacity: 0; transform: translateY(10px) scale(.97);
|
||||||
|
transition: opacity .2s ease, transform .2s ease, overlay .2s ease allow-discrete, display .2s ease allow-discrete; }
|
||||||
|
dialog[open] { opacity: 1; transform: none; }
|
||||||
|
dialog::backdrop { opacity: 0; transition: opacity .2s ease, overlay .2s ease allow-discrete, display .2s ease allow-discrete; }
|
||||||
|
dialog[open]::backdrop { opacity: 1; }
|
||||||
|
@starting-style {
|
||||||
|
dialog[open] { opacity: 0; transform: translateY(10px) scale(.97); }
|
||||||
|
dialog[open]::backdrop { opacity: 0; }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) { dialog, dialog::backdrop { transition: none; } }
|
||||||
|
.dlg-h { padding: 14px 18px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 10px; }
|
||||||
|
.dlg-b { padding: 16px 18px; }
|
||||||
|
/* The account button in the top bar, and its dialog. */
|
||||||
|
header.topbar nav .acct-btn { margin-left: 22px; width: 34px; height: 34px; padding: 0; vertical-align: middle; }
|
||||||
|
#acctdlg { max-width: 480px; }
|
||||||
|
.acct-who { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.acct-ava { width: 40px; height: 40px; border-radius: 50%; background: #12202f; border: 1px solid var(--border);
|
||||||
|
display: inline-flex; align-items: center; justify-content: center; font-weight: 600; color: var(--accent); flex: none; }
|
||||||
|
.acct-name { font-weight: 600; }
|
||||||
|
.acct-mode { font-size: 13px; }
|
||||||
|
.acct h3 { font-size: 15px; margin: 18px 0 4px; }
|
||||||
|
.acct-hint { font-size: 13px; margin: 0 0 10px; }
|
||||||
|
.acct-keys { list-style: none; margin: 0; padding: 0; }
|
||||||
|
.acct-keys li { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--border); font-size: 14px; }
|
||||||
|
.acct-keys li .muted { font-size: 12px; }
|
||||||
|
.acct-kicon { color: var(--accent); display: inline-flex; flex: none; }
|
||||||
|
.acct-kinfo { flex: 1; min-width: 0; overflow-wrap: anywhere; }
|
||||||
|
.acct-add { display: flex; gap: 8px; margin-top: 12px; }
|
||||||
|
.acct-add input { flex: 1; min-width: 0; }
|
||||||
|
.acct-add button { flex: none; }
|
||||||
|
.acct-msg { min-height: 20px; margin: 8px 0 0; font-size: 13px; color: var(--ok); }
|
||||||
|
.acct-msg.bad { color: var(--danger); }
|
||||||
|
.acct-pw { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 14px 0 0; padding-top: 12px;
|
||||||
|
border-top: 1px solid var(--border); color: var(--text); font-size: 14px; cursor: pointer; }
|
||||||
|
.acct-pw b { display: block; font-weight: 500; }
|
||||||
|
.acct-pw small { display: block; font-size: 12px; margin-top: 2px; }
|
||||||
|
/* A switch: a checkbox drawn as a sliding knob. */
|
||||||
|
input.sw { -webkit-appearance: none; appearance: none; flex: none; width: 40px; height: 22px; margin: 0; border-radius: 11px;
|
||||||
|
background: var(--border); position: relative; cursor: pointer; transition: background .2s; }
|
||||||
|
input.sw::after { content: ""; position: absolute; top: 3px; left: 3px; width: 16px; height: 16px; border-radius: 50%;
|
||||||
|
background: #fff; transition: transform .2s; }
|
||||||
|
input.sw:checked { background: var(--accent); }
|
||||||
|
input.sw:checked::after { transform: translateX(18px); }
|
||||||
|
input.sw:disabled { opacity: .45; cursor: not-allowed; }
|
||||||
|
.acct-chpw { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); }
|
||||||
|
.acct-chpw summary { cursor: pointer; font-size: 14px; color: var(--text); }
|
||||||
|
.acct-pwform { display: grid; gap: 8px; margin-top: 10px; }
|
||||||
|
.acct-pwform button { justify-self: end; }
|
||||||
|
.acct-pin { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); }
|
||||||
|
.acct-pin summary { cursor: pointer; font-size: 14px; color: var(--text); }
|
||||||
|
.acct-pin small { color: var(--muted); font-size: 12px; }
|
||||||
|
.acct-pinform { display: grid; gap: 8px; margin-top: 10px; }
|
||||||
|
.acct-pinform input:not(:placeholder-shown) { letter-spacing: .4em; font-family: ui-monospace, Menlo, monospace; }
|
||||||
|
.acct-pinform .row { justify-content: flex-end; }
|
||||||
|
.acct-pingrace { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 12px; font-size: 13px; color: var(--muted); }
|
||||||
|
.acct-pingrace[hidden] { display: none; }
|
||||||
|
.acct-pingrace select { width: auto; flex: none; }
|
||||||
|
.acct-pinmsg { margin: 8px 0 0; font-size: 13px; color: var(--ok, #3fb950); min-height: 0; }
|
||||||
|
.acct-pinmsg.bad { color: var(--danger); }
|
||||||
|
.acct-out { margin-top: 10px; padding-top: 14px; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; }
|
||||||
|
/* Sign-in page. */
|
||||||
|
.or { display: flex; align-items: center; gap: 10px; color: var(--muted); font-size: 13px; margin: 18px 0 4px; }
|
||||||
|
.or::before, .or::after { content: ""; flex: 1; border-top: 1px solid var(--border); }
|
||||||
|
.or + .keybtn { margin-top: 10px; }
|
||||||
|
.keybtn { width: 100%; display: inline-flex; align-items: center; justify-content: center; gap: 8px; }
|
||||||
|
|
||||||
/* Phones. A table marked .stack turns each row into a block of
|
/* Phones. A table marked .stack turns each row into a block of
|
||||||
"label: value" lines, the labels coming from data-l on the cells; a cell
|
"label: value" lines, the labels coming from data-l on the cells; a cell
|
||||||
marked .lead goes first, as the row's title. */
|
marked .lead goes first, as the row's title. */
|
||||||
@@ -112,6 +225,9 @@ pre { padding: 12px; overflow-x: auto; font-size: 13px; white-space: pre-wrap; w
|
|||||||
overflow-x: auto; white-space: nowrap; scrollbar-width: none; }
|
overflow-x: auto; white-space: nowrap; scrollbar-width: none; }
|
||||||
header.topbar nav::-webkit-scrollbar { display: none; }
|
header.topbar nav::-webkit-scrollbar { display: none; }
|
||||||
header.topbar nav > * { margin-left: 0 !important; flex: none; }
|
header.topbar nav > * { margin-left: 0 !important; flex: none; }
|
||||||
|
.acct-add { flex-wrap: wrap; }
|
||||||
|
.acct-add button { width: 100%; }
|
||||||
|
.acct-keys li { flex-wrap: wrap; }
|
||||||
.wrap { padding: 16px 12px; }
|
.wrap { padding: 16px 12px; }
|
||||||
.card { padding: 16px; border-radius: 8px; }
|
.card { padding: 16px; border-radius: 8px; }
|
||||||
h1 { font-size: 20px; }
|
h1 { font-size: 20px; }
|
||||||
@@ -130,3 +246,16 @@ pre { padding: 12px; overflow-x: auto; font-size: 13px; white-space: pre-wrap; w
|
|||||||
table.stack td.actions { width: auto; padding-top: 8px; justify-content: flex-start; }
|
table.stack td.actions { width: auto; padding-top: 8px; justify-content: flex-start; }
|
||||||
table.stack td:empty { display: none; }
|
table.stack td:empty { display: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Scrollbars as on a Mac: thin, no track, seen only while scrolling or with
|
||||||
|
the pointer at the edge, then fading out (scroll.js sets --sb from 0 to 1).
|
||||||
|
Nothing to scroll, nothing shown. */
|
||||||
|
* { --sb: 0; }
|
||||||
|
::-webkit-scrollbar { width: 8px; height: 8px; background: transparent; }
|
||||||
|
::-webkit-scrollbar-track, ::-webkit-scrollbar-corner { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb { border-radius: 4px; border: 2px solid transparent; background-clip: padding-box;
|
||||||
|
background-color: rgba(62, 68, 78, calc(var(--sb) * .9)); }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background-color: rgba(80, 87, 98, calc(var(--sb) * .95)); }
|
||||||
|
@supports not selector(::-webkit-scrollbar) {
|
||||||
|
* { scrollbar-width: thin; scrollbar-color: rgba(62, 68, 78, calc(var(--sb) * .9)) transparent; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
// SQLite editor of the file manager: the agent opens the database and runs
|
||||||
|
// the queries, so a database in use keeps its locks. Tables and views in
|
||||||
|
// tabs, rows a page at a time; a click on a value edits it (Enter saves,
|
||||||
|
// Esc cancels, Shift+Enter a new line, the ∅ button makes it NULL). Rows are
|
||||||
|
// added and deleted, and a tab runs any SQL.
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
var PAGE = 200;
|
||||||
|
var esc = function (s) { return String(s).replace(/[&<>"]/g, function (c) { return {'&': '&', '<': '<', '>': '>', '"': '"'}[c]; }); };
|
||||||
|
|
||||||
|
function cellHTML(c) {
|
||||||
|
if (c.t === 'n') return '<i class="dbx-null">NULL</i>';
|
||||||
|
if (c.t === 'b') return '<i class="dbx-null">BLOB ' + esc(c.v) + ' Б</i>';
|
||||||
|
var v = c.v || '';
|
||||||
|
return esc(v.length > 300 ? v.slice(0, 300) + '…' : v);
|
||||||
|
}
|
||||||
|
// The type a typed value goes as: what the value was, or what the column
|
||||||
|
// is declared as, when the text fits it.
|
||||||
|
function typeFor(text, was, decl) {
|
||||||
|
var d = (decl || '').toUpperCase();
|
||||||
|
var int = /^\s*-?\d+\s*$/.test(text), num = text.trim() !== '' && isFinite(Number(text));
|
||||||
|
if (was === 'i' || (was !== 's' && /INT/.test(d))) return int ? 'i' : num ? 'r' : 's';
|
||||||
|
if (was === 'r' || (was !== 's' && /REAL|FLOA|DOUB|NUM|DEC/.test(d))) return num ? 'r' : 's';
|
||||||
|
return 's';
|
||||||
|
}
|
||||||
|
|
||||||
|
function open(box, path, call, toast) {
|
||||||
|
var S = {tables: [], cur: null, offset: 0, data: null, sel: new Set()};
|
||||||
|
box.innerHTML =
|
||||||
|
'<div class="dbx">' +
|
||||||
|
'<div class="dbx-tabs" role="tablist"></div>' +
|
||||||
|
'<div class="dbx-tools">' +
|
||||||
|
' <span class="muted dbx-info"></span>' +
|
||||||
|
' <span class="dbx-pager"><button type="button" class="btn ghost small icon dbx-prev" title="Назад" aria-label="Назад">‹</button>' +
|
||||||
|
' <span class="muted dbx-range"></span><button type="button" class="btn ghost small icon dbx-next" title="Дальше" aria-label="Дальше">›</button></span>' +
|
||||||
|
' <span class="dbx-sp"></span>' +
|
||||||
|
' <button type="button" class="btn ghost small dbx-add">+ Строка</button>' +
|
||||||
|
' <button type="button" class="btn danger small dbx-del" disabled>Удалить</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="dbx-grid"></div>' +
|
||||||
|
'<div class="dbx-sql" hidden>' +
|
||||||
|
' <textarea class="dbx-q" spellcheck="false" placeholder="SELECT * FROM … - Ctrl+Enter выполнить"></textarea>' +
|
||||||
|
' <div class="dbx-sqlbar"><button type="button" class="dbx-run">Выполнить</button><span class="muted dbx-sqlmsg"></span></div>' +
|
||||||
|
' <div class="dbx-sqlres"></div>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>';
|
||||||
|
var $ = function (s) { return box.querySelector(s); };
|
||||||
|
|
||||||
|
function busy(on) { box.querySelector('.dbx').classList.toggle('busy', on); }
|
||||||
|
function req(op, args) {
|
||||||
|
args.path = path;
|
||||||
|
busy(true);
|
||||||
|
return call(op, args).then(function (r) { busy(false); return r; }, function (e) { busy(false); throw e; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function tabs() {
|
||||||
|
var t = $('.dbx-tabs');
|
||||||
|
t.innerHTML = '';
|
||||||
|
S.tables.forEach(function (x) {
|
||||||
|
var b = document.createElement('button');
|
||||||
|
b.type = 'button';
|
||||||
|
b.textContent = x.name;
|
||||||
|
if (x.type === 'view') b.title = 'представление - только чтение';
|
||||||
|
b.className = x.name === S.cur ? 'on' : '';
|
||||||
|
b.onclick = function () { show(x.name, 0); };
|
||||||
|
t.appendChild(b);
|
||||||
|
});
|
||||||
|
var q = document.createElement('button');
|
||||||
|
q.type = 'button';
|
||||||
|
q.textContent = 'SQL';
|
||||||
|
q.className = 'dbx-sqltab' + (S.cur === null ? ' on' : '');
|
||||||
|
q.onclick = sqlTab;
|
||||||
|
t.appendChild(q);
|
||||||
|
}
|
||||||
|
|
||||||
|
function show(name, offset) {
|
||||||
|
S.cur = name; S.offset = offset; S.sel.clear();
|
||||||
|
tabs();
|
||||||
|
$('.dbx-sql').hidden = true;
|
||||||
|
$('.dbx-grid').hidden = false;
|
||||||
|
$('.dbx-tools').hidden = false;
|
||||||
|
return req('db.rows', {table: name, offset: offset, limit: PAGE}).then(function (r) {
|
||||||
|
if (!r.ok) { $('.dbx-grid').innerHTML = '<p class="dv-note">' + esc(r.error || 'ошибка') + '</p>'; return; }
|
||||||
|
S.data = r.data;
|
||||||
|
draw();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function draw() {
|
||||||
|
var d = S.data, t = d.table, editable = !!t.key;
|
||||||
|
// An INTEGER PRIMARY KEY column already shows the rowid.
|
||||||
|
var showRowid = t.key === 'rowid' && !t.alias;
|
||||||
|
$('.dbx-info').textContent = d.total + ' ' + plural(d.total) + (t.type === 'view' ? ' · представление' : !editable ? ' · только чтение' : '');
|
||||||
|
$('.dbx-range').textContent = d.rows.length ? (d.offset + 1) + '–' + (d.offset + d.rows.length) : '';
|
||||||
|
$('.dbx-prev').disabled = d.offset <= 0;
|
||||||
|
$('.dbx-next').disabled = d.offset + d.rows.length >= d.total;
|
||||||
|
$('.dbx-pager').hidden = d.total <= PAGE;
|
||||||
|
$('.dbx-add').hidden = t.type !== 'table';
|
||||||
|
$('.dbx-del').hidden = !editable;
|
||||||
|
selInfo();
|
||||||
|
var h = '<table class="dbx-t"><thead><tr>' + (editable ? '<th class="dbx-ck"><input type="checkbox" class="dbx-all" aria-label="Выбрать все"></th>' : '') +
|
||||||
|
(showRowid ? '<th class="dbx-k">rowid</th>' : '');
|
||||||
|
t.columns.forEach(function (c) {
|
||||||
|
h += '<th title="' + esc(c.type + (c.pk ? ' · ключ' : '') + (c.notnull ? ' · NOT NULL' : '')) + '">' + esc(c.name) +
|
||||||
|
(c.type ? '<small>' + esc(c.type.toLowerCase()) + '</small>' : '') + '</th>';
|
||||||
|
});
|
||||||
|
h += '</tr></thead><tbody>';
|
||||||
|
d.rows.forEach(function (row, i) {
|
||||||
|
h += '<tr data-i="' + i + '">' + (editable ? '<td class="dbx-ck"><input type="checkbox" aria-label="Выбрать строку"></td>' : '') +
|
||||||
|
(showRowid ? '<td class="dbx-k">' + esc(row.k) + '</td>' : '');
|
||||||
|
row.v.forEach(function (c, j) {
|
||||||
|
h += '<td data-j="' + j + '" class="' + (c.t === 'i' || c.t === 'r' ? 'num' : '') + (editable && c.t !== 'b' ? ' ed' : '') + '">' + cellHTML(c) + '</td>';
|
||||||
|
});
|
||||||
|
h += '</tr>';
|
||||||
|
});
|
||||||
|
h += '</tbody></table>';
|
||||||
|
if (!d.rows.length) h += '<p class="dv-note">строк нет</p>';
|
||||||
|
$('.dbx-grid').innerHTML = h;
|
||||||
|
$('.dbx-grid').scrollTop = 0;
|
||||||
|
fixWidths($('.dbx-grid table'));
|
||||||
|
}
|
||||||
|
// The columns keep the widths they first came out at, so a value being
|
||||||
|
// edited does not move the table about.
|
||||||
|
function fixWidths(table) {
|
||||||
|
if (!table) return;
|
||||||
|
var ths = table.querySelectorAll('thead th'), ws = [], sum = 0;
|
||||||
|
ths.forEach(function (th) { var w = Math.ceil(th.getBoundingClientRect().width); ws.push(w); sum += w; });
|
||||||
|
ths.forEach(function (th, i) { th.style.width = ws[i] + 'px'; });
|
||||||
|
table.style.width = sum + 'px';
|
||||||
|
table.style.tableLayout = 'fixed';
|
||||||
|
}
|
||||||
|
function plural(n) {
|
||||||
|
var m10 = n % 10, m100 = n % 100;
|
||||||
|
return m10 === 1 && m100 !== 11 ? 'строка' : m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14) ? 'строки' : 'строк';
|
||||||
|
}
|
||||||
|
function selInfo() {
|
||||||
|
var b = $('.dbx-del');
|
||||||
|
b.disabled = !S.sel.size;
|
||||||
|
b.textContent = S.sel.size ? 'Удалить (' + S.sel.size + ')' : 'Удалить';
|
||||||
|
}
|
||||||
|
|
||||||
|
$('.dbx-prev').onclick = function () { show(S.cur, Math.max(0, S.offset - PAGE)); };
|
||||||
|
$('.dbx-next').onclick = function () { show(S.cur, S.offset + PAGE); };
|
||||||
|
|
||||||
|
// ---- selecting rows ----
|
||||||
|
$('.dbx-grid').addEventListener('change', function (ev) {
|
||||||
|
var el = ev.target;
|
||||||
|
if (el.classList.contains('dbx-all')) {
|
||||||
|
box.querySelectorAll('tbody .dbx-ck input').forEach(function (c, i) { c.checked = el.checked; el.checked ? S.sel.add(i) : S.sel.delete(i); });
|
||||||
|
} else if (el.closest('tbody .dbx-ck')) {
|
||||||
|
var i = +el.closest('tr').dataset.i;
|
||||||
|
el.checked ? S.sel.add(i) : S.sel.delete(i);
|
||||||
|
}
|
||||||
|
selInfo();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- editing a value ----
|
||||||
|
var editing = null;
|
||||||
|
$('.dbx-grid').addEventListener('click', function (ev) {
|
||||||
|
var td = ev.target.closest('td.ed');
|
||||||
|
if (!td || td.querySelector('textarea') || ev.target.closest('.dbx-edit')) return;
|
||||||
|
edit(td);
|
||||||
|
});
|
||||||
|
function edit(td) {
|
||||||
|
if (editing) finish(editing, true);
|
||||||
|
var i = +td.parentNode.dataset.i, j = +td.dataset.j;
|
||||||
|
var row = S.data.rows[i], cell = row.v[j], col = S.data.table.columns[j];
|
||||||
|
var hgt = td.offsetHeight;
|
||||||
|
td.classList.add('editing');
|
||||||
|
td.innerHTML = '<div class="dbx-edit"><textarea spellcheck="false"></textarea><button type="button" class="dbx-nullb" title="Сделать NULL" aria-label="Сделать NULL">∅</button></div>';
|
||||||
|
var ta = td.querySelector('textarea');
|
||||||
|
ta.value = cell.t === 'n' ? '' : cell.v;
|
||||||
|
ta.placeholder = cell.t === 'n' ? 'NULL' : '';
|
||||||
|
ta.style.height = Math.max(hgt, 30) + 'px';
|
||||||
|
ta.focus();
|
||||||
|
ta.select();
|
||||||
|
editing = {td: td, i: i, j: j, ta: ta, cell: cell, col: col, nul: false};
|
||||||
|
ta.addEventListener('keydown', function (ev) {
|
||||||
|
if (ev.key === 'Enter' && !ev.shiftKey) { ev.preventDefault(); finish(editing, true); }
|
||||||
|
else if (ev.key === 'Escape') { ev.preventDefault(); ev.stopPropagation(); finish(editing, false); }
|
||||||
|
else if (ev.key === 'Tab') {
|
||||||
|
ev.preventDefault();
|
||||||
|
var cur = editing, next = ev.shiftKey ? cur.td.previousElementSibling : cur.td.nextElementSibling;
|
||||||
|
finish(cur, true);
|
||||||
|
if (next && next.classList.contains('ed')) edit(next);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ta.addEventListener('blur', function () { setTimeout(function () { if (editing && editing.ta === ta && document.activeElement !== td.querySelector('.dbx-nullb')) finish(editing, true); }, 120); });
|
||||||
|
td.querySelector('.dbx-nullb').onmousedown = function (ev) { ev.preventDefault(); editing.nul = true; finish(editing, true); };
|
||||||
|
}
|
||||||
|
function finish(e, keep) {
|
||||||
|
if (!e || editing !== e) return;
|
||||||
|
editing = null;
|
||||||
|
var text = e.ta.value, cell = e.cell;
|
||||||
|
var value = e.nul ? {t: 'n'} : {t: typeFor(text, cell.t, e.col.type), v: text};
|
||||||
|
var same = e.nul ? cell.t === 'n' : (cell.t !== 'n' || text !== '') && text === (cell.v || '') && (cell.t === value.t || cell.t === 's' || value.t === 's');
|
||||||
|
if (cell.t === 'n' && !e.nul && text === '') same = true;
|
||||||
|
e.td.classList.remove('editing');
|
||||||
|
if (!keep || same) { e.td.innerHTML = cellHTML(cell); return; }
|
||||||
|
e.td.innerHTML = cellHTML(value);
|
||||||
|
e.td.classList.add('saving');
|
||||||
|
req('db.set', {table: S.cur, key: S.data.rows[e.i].k, column: e.col.name, value: value}).then(function (r) {
|
||||||
|
e.td.classList.remove('saving');
|
||||||
|
if (!r.ok) {
|
||||||
|
e.td.innerHTML = cellHTML(cell);
|
||||||
|
flash(e.td, 'bad');
|
||||||
|
toast(r.error || 'не сохранилось', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var got = r.data.value;
|
||||||
|
S.data.rows[e.i].v[e.j] = got;
|
||||||
|
// A changed key column moves the row's key too.
|
||||||
|
if (r.data.key !== undefined) S.data.rows[e.i].k = r.data.key;
|
||||||
|
e.td.innerHTML = cellHTML(got);
|
||||||
|
e.td.className = (got.t === 'i' || got.t === 'r' ? 'num' : '') + ' ed';
|
||||||
|
flash(e.td, 'ok');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function flash(td, kind) {
|
||||||
|
td.classList.remove('flash-ok', 'flash-bad');
|
||||||
|
void td.offsetWidth;
|
||||||
|
td.classList.add('flash-' + kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- adding and deleting rows ----
|
||||||
|
$('.dbx-add').onclick = function () {
|
||||||
|
if (box.querySelector('tr.dbx-new')) return;
|
||||||
|
var t = S.data.table, tbody = box.querySelector('.dbx-t tbody');
|
||||||
|
var tr = document.createElement('tr');
|
||||||
|
tr.className = 'dbx-new';
|
||||||
|
var h = (t.key ? '<td class="dbx-ck"></td>' : '') + (t.key === 'rowid' && !t.alias ? '<td class="dbx-k">новая</td>' : '');
|
||||||
|
t.columns.forEach(function (c) {
|
||||||
|
h += '<td><input type="text" data-c="' + esc(c.name) + '" placeholder="' + esc(c.default ? 'по умолч.: ' + c.default : c.notnull ? 'обязательно' : 'NULL') + '"></td>';
|
||||||
|
});
|
||||||
|
tr.innerHTML = h;
|
||||||
|
tbody.insertBefore(tr, tbody.firstChild);
|
||||||
|
var bar = document.createElement('tr');
|
||||||
|
bar.className = 'dbx-newbar';
|
||||||
|
bar.innerHTML = '<td colspan="' + (t.columns.length + 2) + '"><button type="button" class="small dbx-addok">Добавить</button> <button type="button" class="btn ghost small dbx-addno">Отмена</button> <span class="muted">пустое поле - значение по умолчанию</span></td>';
|
||||||
|
tbody.insertBefore(bar, tr.nextSibling);
|
||||||
|
$('.dbx-grid').scrollTop = 0;
|
||||||
|
tr.querySelector('input').focus();
|
||||||
|
var done = function () { tr.remove(); bar.remove(); };
|
||||||
|
bar.querySelector('.dbx-addno').onclick = done;
|
||||||
|
var add = function () {
|
||||||
|
var values = {};
|
||||||
|
tr.querySelectorAll('input').forEach(function (inp) {
|
||||||
|
if (inp.value === '') return;
|
||||||
|
var c = t.columns.filter(function (x) { return x.name === inp.dataset.c; })[0];
|
||||||
|
values[c.name] = {t: typeFor(inp.value, '', c.type), v: inp.value};
|
||||||
|
});
|
||||||
|
req('db.add', {table: S.cur, values: values}).then(function (r) {
|
||||||
|
if (!r.ok) { toast(r.error || 'не добавилось', true); return; }
|
||||||
|
toast('строка добавлена');
|
||||||
|
var total = S.data.total + 1;
|
||||||
|
show(S.cur, Math.floor((total - 1) / PAGE) * PAGE);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
bar.querySelector('.dbx-addok').onclick = add;
|
||||||
|
tr.addEventListener('keydown', function (ev) {
|
||||||
|
if (ev.key === 'Enter') { ev.preventDefault(); add(); }
|
||||||
|
if (ev.key === 'Escape') { ev.preventDefault(); ev.stopPropagation(); done(); }
|
||||||
|
});
|
||||||
|
};
|
||||||
|
$('.dbx-del').onclick = function () {
|
||||||
|
var keys = [];
|
||||||
|
S.sel.forEach(function (i) { keys.push(S.data.rows[i].k); });
|
||||||
|
if (!keys.length || !confirm('Удалить строк: ' + keys.length + '?')) return;
|
||||||
|
req('db.del', {table: S.cur, keys: keys}).then(function (r) {
|
||||||
|
if (!r.ok) { toast(r.error || 'не удалилось', true); return; }
|
||||||
|
toast('удалено строк: ' + r.data.deleted);
|
||||||
|
show(S.cur, S.offset);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- SQL ----
|
||||||
|
function sqlTab() {
|
||||||
|
S.cur = null;
|
||||||
|
tabs();
|
||||||
|
$('.dbx-grid').hidden = true;
|
||||||
|
$('.dbx-tools').hidden = true;
|
||||||
|
$('.dbx-sql').hidden = false;
|
||||||
|
$('.dbx-q').focus();
|
||||||
|
}
|
||||||
|
function run() {
|
||||||
|
var q = $('.dbx-q').value;
|
||||||
|
if (!q.trim()) return;
|
||||||
|
$('.dbx-sqlmsg').textContent = '';
|
||||||
|
req('db.sql', {sql: q}).then(function (r) {
|
||||||
|
var out = $('.dbx-sqlres');
|
||||||
|
if (!r.ok) { out.innerHTML = ''; $('.dbx-sqlmsg').textContent = r.error || 'ошибка'; $('.dbx-sqlmsg').className = 'dbx-sqlmsg bad'; return; }
|
||||||
|
$('.dbx-sqlmsg').className = 'muted dbx-sqlmsg';
|
||||||
|
var d = r.data;
|
||||||
|
if (d.columns) {
|
||||||
|
$('.dbx-sqlmsg').textContent = 'строк: ' + d.rows.length + (d.more ? ' (показаны первые 500)' : '');
|
||||||
|
var h = '<table class="dbx-t"><thead><tr>' + d.columns.map(function (c) { return '<th>' + esc(c) + '</th>'; }).join('') + '</tr></thead><tbody>';
|
||||||
|
d.rows.forEach(function (row) {
|
||||||
|
h += '<tr>' + row.map(function (c) { return '<td class="' + (c.t === 'i' || c.t === 'r' ? 'num' : '') + '">' + cellHTML(c) + '</td>'; }).join('') + '</tr>';
|
||||||
|
});
|
||||||
|
out.innerHTML = h + '</tbody></table>';
|
||||||
|
} else {
|
||||||
|
out.innerHTML = '';
|
||||||
|
$('.dbx-sqlmsg').textContent = 'готово · изменено строк: ' + d.changed;
|
||||||
|
loadTables(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
$('.dbx-run').onclick = run;
|
||||||
|
$('.dbx-q').addEventListener('keydown', function (ev) {
|
||||||
|
if (ev.key === 'Enter' && (ev.ctrlKey || ev.metaKey)) { ev.preventDefault(); run(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
function loadTables(keep) {
|
||||||
|
return req('db.tables', {}).then(function (r) {
|
||||||
|
if (!r.ok) return r;
|
||||||
|
S.tables = r.data.tables;
|
||||||
|
if (keep) { tabs(); return r; }
|
||||||
|
if (S.tables.length) show(S.tables[0].name, 0); else sqlTab();
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return loadTables(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.DBEdit = {open: open};
|
||||||
|
})();
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
// The PIN lock: in a new browser tab the panel asks for the admin's PIN
|
||||||
|
// before showing anything. The tab remembers it was unlocked (sessionStorage
|
||||||
|
// lives as long as the tab, through reloads, and a new tab starts empty), so
|
||||||
|
// a reload does not ask again. Loaded in <head>, before the page shows.
|
||||||
|
// A device can be set to trust the PIN for a while: for that many minutes
|
||||||
|
// after it was entered, new tabs there open without asking (localStorage,
|
||||||
|
// this browser only).
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
var KEY = 'lpra.pin.ok';
|
||||||
|
var ok = false;
|
||||||
|
try { ok = sessionStorage.getItem(KEY) === '1'; } catch (e) { /* no storage: ask */ }
|
||||||
|
if (ok) return;
|
||||||
|
try {
|
||||||
|
if (Date.now() < Number(localStorage.getItem('lpra.pin.until') || 0)) {
|
||||||
|
sessionStorage.setItem(KEY, '1');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) { /* ask */ }
|
||||||
|
var root = document.documentElement;
|
||||||
|
root.classList.add('pin-wait');
|
||||||
|
function release() { root.classList.remove('pin-wait'); }
|
||||||
|
function remember() { try { sessionStorage.setItem(KEY, '1'); } catch (e) { /* asked again next load */ } }
|
||||||
|
// After the PIN itself: no asking on this device for the minutes it trusts.
|
||||||
|
function trust() {
|
||||||
|
try {
|
||||||
|
var min = Number(localStorage.getItem('lpra.pin.grace') || 0);
|
||||||
|
if (min > 0) localStorage.setItem('lpra.pin.until', String(Date.now() + min * 60000));
|
||||||
|
} catch (e) { /* asks every tab */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch('/api/panel/pin', {cache: 'no-store'})
|
||||||
|
.then(function (r) { return r.ok ? r.json() : {pin: false}; })
|
||||||
|
.then(function (d) {
|
||||||
|
if (!d.pin) { remember(); release(); return; }
|
||||||
|
if (document.body) lock(); else document.addEventListener('DOMContentLoaded', lock);
|
||||||
|
})
|
||||||
|
.catch(release);
|
||||||
|
|
||||||
|
function lock() {
|
||||||
|
var LOCK = '<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="11" width="16" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg>';
|
||||||
|
var BACK = '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 5H9l-6 7 6 7h12a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1z"/><path d="m16 9-5 6M11 9l5 6"/></svg>';
|
||||||
|
var box = document.createElement('div');
|
||||||
|
box.className = 'pinlock';
|
||||||
|
box.setAttribute('role', 'dialog');
|
||||||
|
box.setAttribute('aria-modal', 'true');
|
||||||
|
box.setAttribute('aria-label', 'Ввод PIN-кода');
|
||||||
|
var keys = '';
|
||||||
|
['1', '2', '3', '4', '5', '6', '7', '8', '9', 'out', '0', 'back'].forEach(function (k) {
|
||||||
|
var label = k === 'back' ? ' aria-label="Стереть">' + BACK : k === 'out' ? ' title="Выйти из панели">Выйти' : '>' + k;
|
||||||
|
keys += '<button type="button" data-k="' + k + '"' + label + '</button>';
|
||||||
|
});
|
||||||
|
box.innerHTML =
|
||||||
|
'<div class="pin-card">' +
|
||||||
|
'<div class="pin-ico">' + LOCK + '</div>' +
|
||||||
|
'<div class="pin-title">Введите PIN-код</div>' +
|
||||||
|
'<div class="pin-dots"><i></i><i></i><i></i><i></i></div>' +
|
||||||
|
'<div class="pin-msg" aria-live="polite"></div>' +
|
||||||
|
'<div class="pin-pad">' + keys + '</div>' +
|
||||||
|
'<form method="post" action="/logout" class="pin-out" hidden></form>' +
|
||||||
|
'</div>';
|
||||||
|
document.body.appendChild(box);
|
||||||
|
requestAnimationFrame(function () { box.classList.add('on'); });
|
||||||
|
var dots = box.querySelectorAll('.pin-dots i'), msg = box.querySelector('.pin-msg'), card = box.querySelector('.pin-card');
|
||||||
|
var code = '', busy = false;
|
||||||
|
function show() {
|
||||||
|
for (var i = 0; i < 4; i++) dots[i].classList.toggle('on', i < code.length);
|
||||||
|
}
|
||||||
|
function press(k) {
|
||||||
|
if (k === 'out') { box.querySelector('.pin-out').submit(); return; }
|
||||||
|
if (busy) return;
|
||||||
|
if (k === 'back') { code = code.slice(0, -1); show(); return; }
|
||||||
|
if (code.length >= 4) return;
|
||||||
|
code += k;
|
||||||
|
msg.textContent = '';
|
||||||
|
card.classList.remove('bad');
|
||||||
|
show();
|
||||||
|
if (code.length === 4) check();
|
||||||
|
}
|
||||||
|
function check() {
|
||||||
|
busy = true;
|
||||||
|
fetch('/api/panel/pin/check', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({pin: code})})
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (r) {
|
||||||
|
if (r.ok) {
|
||||||
|
remember();
|
||||||
|
trust();
|
||||||
|
box.classList.add('done');
|
||||||
|
release();
|
||||||
|
setTimeout(function () { box.remove(); }, 320);
|
||||||
|
document.removeEventListener('keydown', onKey, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (r.out) { location.href = '/login'; return; }
|
||||||
|
card.classList.remove('bad');
|
||||||
|
void card.offsetWidth; // restart the shake
|
||||||
|
card.classList.add('bad');
|
||||||
|
msg.textContent = r.error + (r.left ? ' - осталось попыток: ' + r.left : '');
|
||||||
|
setTimeout(function () { code = ''; show(); busy = false; }, 380);
|
||||||
|
})
|
||||||
|
.catch(function () { msg.textContent = 'нет связи с сервером'; code = ''; show(); busy = false; });
|
||||||
|
}
|
||||||
|
box.querySelector('.pin-pad').addEventListener('click', function (ev) {
|
||||||
|
var b = ev.target.closest('button');
|
||||||
|
if (b) press(b.dataset.k);
|
||||||
|
});
|
||||||
|
// Typed digits, ahead of anything on the page (the terminal included).
|
||||||
|
function onKey(ev) {
|
||||||
|
if (/^[0-9]$/.test(ev.key)) press(ev.key);
|
||||||
|
else if (ev.key === 'Backspace') press('back');
|
||||||
|
else if (ev.key !== 'Tab') return;
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', onKey, true);
|
||||||
|
if (document.activeElement && document.activeElement.blur) document.activeElement.blur();
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// Mac-like scrollbars: a scrolled element shows its thin scrollbar, and so
|
||||||
|
// does one whose edge the pointer is at; after a second at rest it fades out.
|
||||||
|
// Elements with nothing to scroll never show one. The look is in app.css.
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
var live = new Map(); // element -> {v: shown 0..1, until: ms to stay}
|
||||||
|
var raf = 0;
|
||||||
|
function el(t) { return t === document || t === window ? document.documentElement : t; }
|
||||||
|
function scrollable(e) {
|
||||||
|
return e.scrollHeight > e.clientHeight + 1 || e.scrollWidth > e.clientWidth + 1;
|
||||||
|
}
|
||||||
|
function show(e, ms) {
|
||||||
|
if (!e || !e.style || !scrollable(e)) return;
|
||||||
|
var s = live.get(e) || {v: 0, until: 0};
|
||||||
|
s.until = Math.max(s.until, performance.now() + ms);
|
||||||
|
live.set(e, s);
|
||||||
|
if (!raf) raf = setTimeout(tick, 16);
|
||||||
|
}
|
||||||
|
var last = 0;
|
||||||
|
// A timer, not animation frames: those stop in a tab out of sight, and the
|
||||||
|
// bar would stay shown.
|
||||||
|
function tick() {
|
||||||
|
var now = performance.now();
|
||||||
|
var dt = Math.min(64, now - (last || now - 16));
|
||||||
|
last = now;
|
||||||
|
live.forEach(function (s, e) {
|
||||||
|
// In fast (about 0.12 s), out slowly (about 0.4 s).
|
||||||
|
var want = now < s.until ? 1 : 0;
|
||||||
|
s.v = want ? Math.min(1, s.v + dt / 120) : Math.max(0, s.v - dt / 400);
|
||||||
|
e.style.setProperty('--sb', s.v.toFixed(3));
|
||||||
|
if (!want && s.v === 0) { e.style.removeProperty('--sb'); live.delete(e); }
|
||||||
|
});
|
||||||
|
raf = live.size ? setTimeout(tick, 16) : 0;
|
||||||
|
if (!raf) last = 0;
|
||||||
|
}
|
||||||
|
document.addEventListener('scroll', function (ev) { show(el(ev.target), 900); }, {capture: true, passive: true});
|
||||||
|
// The pointer at a scrollable element's right or bottom edge shows its bar.
|
||||||
|
var moved = null;
|
||||||
|
document.addEventListener('pointermove', function (ev) {
|
||||||
|
if (ev.pointerType !== 'mouse') return;
|
||||||
|
if (!moved) requestAnimationFrame(function () {
|
||||||
|
var m = moved; moved = null;
|
||||||
|
for (var e = m.target; e && e.nodeType === 1; e = e.parentElement) {
|
||||||
|
if (!scrollable(e)) continue;
|
||||||
|
var st = getComputedStyle(e);
|
||||||
|
if (!/(auto|scroll)/.test(st.overflowY + st.overflowX) && e !== document.documentElement) continue;
|
||||||
|
var r = e === document.documentElement ? {right: innerWidth, bottom: innerHeight} : e.getBoundingClientRect();
|
||||||
|
if (m.x >= r.right - 14 || m.y >= r.bottom - 14) show(e, 700);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
moved = {target: ev.target, x: ev.clientX, y: ev.clientY};
|
||||||
|
}, {passive: true});
|
||||||
|
})();
|
||||||
@@ -0,0 +1,858 @@
|
|||||||
|
// Terminal page: shells on one or more machines, in tabs. With one tab the
|
||||||
|
// page is a plain terminal and a faint "+" in the corner; a second tab brings
|
||||||
|
// the tab strip in. On a computer a tab can be dragged onto the current one
|
||||||
|
// to share it - up to four terminals, split side by side or one above the
|
||||||
|
// other - and a terminal dragged out of it back to the strip. Splits resize
|
||||||
|
// by their gutters; a tab is renamed by a double click on its title.
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
const PAGE = window.TERM_PAGE;
|
||||||
|
const touch = matchMedia('(pointer: coarse)').matches;
|
||||||
|
const desk = !touch; // drag and drop, splits: computers only
|
||||||
|
if (touch) document.body.classList.add('touch');
|
||||||
|
const $ = id => document.getElementById(id);
|
||||||
|
const statusEl = $('status'), area = $('area'), tablist = $('tablist'), addBtn = $('tabadd'),
|
||||||
|
floatAdd = $('floatadd'), addMenu = $('addmenu'), hint = $('hint'), mark = $('tabmark');
|
||||||
|
const base = '/api/panel/term/';
|
||||||
|
const GAP = 6;
|
||||||
|
const X = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>';
|
||||||
|
|
||||||
|
function bytesToB64(bytes) {
|
||||||
|
let bin = '';
|
||||||
|
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
|
||||||
|
return btoa(bin);
|
||||||
|
}
|
||||||
|
function b64ToBytes(b64) {
|
||||||
|
const bin = atob(b64), u = new Uint8Array(bin.length);
|
||||||
|
for (let i = 0; i < bin.length; i++) u[i] = bin.charCodeAt(i);
|
||||||
|
return u;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- copy and paste by keys ----------
|
||||||
|
// Off a Mac, where Cmd does it, Ctrl+C copies when there is a selection and
|
||||||
|
// interrupts as ever when there is none; Ctrl+Shift+C always copies (and is
|
||||||
|
// kept from the browser, whose developer tools it would open); Ctrl+V and
|
||||||
|
// Ctrl+Shift+V paste. Keys go by position, so a Russian layout works too.
|
||||||
|
const mac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
|
||||||
|
function copyText(text) {
|
||||||
|
if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(text);
|
||||||
|
// Plain http: the old way, through a field that is there for a moment.
|
||||||
|
const ta = document.createElement('textarea'), back = document.activeElement;
|
||||||
|
ta.value = text;
|
||||||
|
ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0';
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.select();
|
||||||
|
const ok = document.execCommand('copy');
|
||||||
|
ta.remove();
|
||||||
|
if (back && back.focus) back.focus();
|
||||||
|
return ok ? Promise.resolve() : Promise.reject(new Error('нет доступа к буферу'));
|
||||||
|
}
|
||||||
|
function clipKeys(s, ev) {
|
||||||
|
if (mac || !ev.ctrlKey || ev.altKey || ev.metaKey) return true;
|
||||||
|
const c = ev.code === 'KeyC', v = ev.code === 'KeyV';
|
||||||
|
if (!c && !v) return true;
|
||||||
|
if (c && !ev.shiftKey && !s.term.hasSelection()) return true; // ^C to the shell
|
||||||
|
// From here the key is ours: xterm does not turn it into a control code.
|
||||||
|
if (ev.type === 'keydown' && c) {
|
||||||
|
ev.preventDefault();
|
||||||
|
if (s.term.hasSelection()) {
|
||||||
|
const text = s.term.getSelection();
|
||||||
|
s.term.clearSelection();
|
||||||
|
copyText(text).then(() => flashStatus('скопировано', 'st-ok'), e => flashStatus('не скопировалось: ' + e.message, 'st-err'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Ctrl+V: the browser's paste goes on into the terminal's text field.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
function flashStatus(t, cls) {
|
||||||
|
statusEl.textContent = t;
|
||||||
|
statusEl.className = cls;
|
||||||
|
clearTimeout(flashStatus.t);
|
||||||
|
flashStatus.t = setTimeout(showStatus, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- one shell ----------
|
||||||
|
class Session {
|
||||||
|
// ssh, when given, is where the agent connects by SSH for this shell:
|
||||||
|
// {host, port, user} and the secret to sign in with, sent once on open.
|
||||||
|
constructor(agent, ssh) {
|
||||||
|
this.agent = agent;
|
||||||
|
this.ssh = ssh || null;
|
||||||
|
this.sid = null; this.closed = false; this.connected = false;
|
||||||
|
this.state = 'wait'; this.text = 'подключение…';
|
||||||
|
this.inSeq = 0; this.inBusy = false; this.inQueue = [];
|
||||||
|
this.el = document.createElement('div');
|
||||||
|
this.el.className = 'pane';
|
||||||
|
this.el.innerHTML = '<div class="pane-head"><span class="dot wait"></span><span class="pane-title"></span>' +
|
||||||
|
'<button type="button" class="pane-x" title="Закрыть терминал" aria-label="Закрыть терминал">' + X + '</button></div>' +
|
||||||
|
'<div class="pane-body"></div>';
|
||||||
|
this.el.querySelector('.pane-title').textContent = who(this);
|
||||||
|
this.el.querySelector('.pane-title').title = ssh ? 'SSH через ' + agent.name : agent.name;
|
||||||
|
if (ssh) this.el.querySelector('.dot').insertAdjacentHTML('afterend', '<span class="badge-ssh">SSH</span>');
|
||||||
|
this.term = new Terminal({
|
||||||
|
cursorBlink: true, fontSize: touch ? 13 : 14,
|
||||||
|
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', theme: { background: '#0c0f13' }
|
||||||
|
});
|
||||||
|
this.fitter = new FitAddon.FitAddon();
|
||||||
|
this.term.loadAddon(this.fitter);
|
||||||
|
this.term.open(this.el.querySelector('.pane-body'));
|
||||||
|
this.term.attachCustomKeyEventHandler(ev => clipKeys(this, ev));
|
||||||
|
this.term.onData(d => this.send(TermKeys.withMods(d)));
|
||||||
|
this.term.onResize(() => this.queueIn({ t: 'r', cols: this.term.cols, rows: this.term.rows }));
|
||||||
|
this.term.onSelectionChange(() => TermKeys.selectionChanged(this));
|
||||||
|
this.el.addEventListener('pointerdown', () => setActivePane(this), true);
|
||||||
|
this.term.textarea.addEventListener('focus', () => setActivePane(this));
|
||||||
|
this.el.querySelector('.pane-x').onclick = ev => { ev.stopPropagation(); closePane(this); };
|
||||||
|
if (desk) this.el.querySelector('.pane-head').addEventListener('pointerdown', ev => startDrag(ev, 'pane', this));
|
||||||
|
this.open();
|
||||||
|
}
|
||||||
|
setState(state, text) {
|
||||||
|
this.state = state; this.text = text;
|
||||||
|
this.el.querySelector('.dot').className = 'dot ' + state;
|
||||||
|
const t = tabOf(this);
|
||||||
|
if (t) renderTab(t);
|
||||||
|
showStatus();
|
||||||
|
}
|
||||||
|
async open() {
|
||||||
|
try {
|
||||||
|
let opt = { method: 'POST' };
|
||||||
|
if (this.ssh) {
|
||||||
|
opt = { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ssh: this.ssh }) };
|
||||||
|
// The password or key goes once, to the agent; the page keeps no copy.
|
||||||
|
this.ssh = { host: this.ssh.host, port: this.ssh.port, user: this.ssh.user };
|
||||||
|
}
|
||||||
|
const r = await fetch(base + encodeURIComponent(this.agent.id) + '/open', opt);
|
||||||
|
if (!r.ok) {
|
||||||
|
let why = 'HTTP ' + r.status;
|
||||||
|
try { why = (await r.json()).error || why; } catch (e) { /* plain text */ }
|
||||||
|
this.setState('err', 'ошибка открытия: ' + why);
|
||||||
|
this.term.write('\r\n\x1b[31m[не удалось открыть сессию: ' + why + ']\x1b[0m\r\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.sid = (await r.json()).session_id;
|
||||||
|
this.setState('wait', 'ожидание агента…');
|
||||||
|
// An agent from before SSH terminals does not answer the request.
|
||||||
|
if (this.ssh) setTimeout(() => {
|
||||||
|
if (!this.closed && !this.ended && this.text === 'ожидание агента…') {
|
||||||
|
this.setState('err', 'агент не начал SSH-подключение');
|
||||||
|
this.term.write('\x1b[33mАгент не ответил. Если он старой версии, обновите его - SSH-терминал появился в новой.\x1b[0m\r\n');
|
||||||
|
}
|
||||||
|
}, 20000);
|
||||||
|
this.fit();
|
||||||
|
// A WebSocket if it connects; the long polls otherwise, and from a
|
||||||
|
// dropped WebSocket on for the rest of this session.
|
||||||
|
if (!(await this.openWS())) this.poll();
|
||||||
|
this.queueIn({ t: 'r', cols: this.term.cols, rows: this.term.rows });
|
||||||
|
} catch (e) {
|
||||||
|
this.setState('err', 'ошибка сети');
|
||||||
|
this.term.write('\r\n\x1b[31m[ошибка сети: ' + (e && e.message ? e.message : e) + ']\x1b[0m\r\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
openWS() {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
if (!window.WebSocket) { resolve(false); return; }
|
||||||
|
const ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + base + this.sid + '/ws');
|
||||||
|
let opened = false;
|
||||||
|
const timer = setTimeout(() => { if (!opened) { ws.close(); resolve(false); } }, 5000);
|
||||||
|
ws.onopen = () => { opened = true; clearTimeout(timer); this.ws = ws; resolve(true); };
|
||||||
|
ws.onmessage = ev => {
|
||||||
|
let j;
|
||||||
|
try { j = JSON.parse(ev.data); } catch (e) { return; }
|
||||||
|
this.take(j);
|
||||||
|
};
|
||||||
|
ws.onclose = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (!opened) { resolve(false); return; }
|
||||||
|
this.ws = null;
|
||||||
|
if (!this.closed && !this.ended) this.poll();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// take applies one answer of the output channel, either transport's.
|
||||||
|
take(j) {
|
||||||
|
if (j.closed) { this.finish('[сессия завершена]'); return false; }
|
||||||
|
for (const f of j.frames || []) {
|
||||||
|
if (f.status) this.sshStatus(f.status, f.note || '');
|
||||||
|
if (f.exit) { this.finish('[сессия завершена]'); return false; }
|
||||||
|
if (f.data) {
|
||||||
|
if (!this.connected && !this.ssh) { this.connected = true; this.setState('ok', 'подключено'); }
|
||||||
|
this.term.write(b64ToBytes(f.data));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// sshStatus shows how the agent's SSH connection goes.
|
||||||
|
sshStatus(st, note) {
|
||||||
|
if (st === 'dial') this.setState('wait', 'SSH: ' + note + '…');
|
||||||
|
else if (st === 'auth') this.setState('wait', 'SSH: ' + note + '…');
|
||||||
|
else if (st === 'ok') { this.connected = true; this.setState('ok', 'SSH: ' + note); }
|
||||||
|
else if (st === 'err') this.setState('err', 'SSH: ' + note);
|
||||||
|
}
|
||||||
|
async poll() {
|
||||||
|
while (!this.closed && this.sid) {
|
||||||
|
let r;
|
||||||
|
try { r = await fetch(base + this.sid + '/out', { cache: 'no-store' }); }
|
||||||
|
catch (e) { await new Promise(res => setTimeout(res, 1000)); continue; }
|
||||||
|
if (this.closed) return;
|
||||||
|
if (!r.ok) { this.finish('[соединение закрыто]'); return; }
|
||||||
|
if (!this.take(await r.json())) return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finish(msg) {
|
||||||
|
if (this.ended) return;
|
||||||
|
this.ended = true;
|
||||||
|
if (this.state !== 'err') this.setState('off', 'соединение закрыто');
|
||||||
|
this.term.write('\r\n\x1b[33m' + msg + '\x1b[0m\r\n');
|
||||||
|
}
|
||||||
|
// Input goes out one request at a time, so on a slow link a keystroke can
|
||||||
|
// never overtake the one before it; a request that fails goes again under
|
||||||
|
// the same number, and the server drops one it already has.
|
||||||
|
queueIn(m) {
|
||||||
|
if (this.closed || !this.sid) return;
|
||||||
|
const q = this.inQueue, last = q[q.length - 1];
|
||||||
|
if (last && last.t === 'i' && m.t === 'i') last.text += m.text;
|
||||||
|
else if (last && last.t === 'r' && m.t === 'r') q[q.length - 1] = m;
|
||||||
|
else q.push(m);
|
||||||
|
this.pumpIn();
|
||||||
|
}
|
||||||
|
async pumpIn() {
|
||||||
|
if (this.inBusy || !this.inQueue.length) return;
|
||||||
|
this.inBusy = true;
|
||||||
|
while (this.inQueue.length && !this.closed) {
|
||||||
|
const m = this.inQueue.shift();
|
||||||
|
const body = m.t === 'i'
|
||||||
|
? { t: 'i', d: bytesToB64(new TextEncoder().encode(m.text)), seq: ++this.inSeq }
|
||||||
|
: { t: 'r', cols: m.cols, rows: m.rows, seq: ++this.inSeq };
|
||||||
|
// On the WebSocket a message goes out in order with no answer to wait
|
||||||
|
// for; numbered as well, so a repeat by the long polls is dropped.
|
||||||
|
if (this.ws && this.ws.readyState === 1) {
|
||||||
|
try { this.ws.send(JSON.stringify(body)); continue; } catch (e) { /* by request below */ }
|
||||||
|
}
|
||||||
|
for (let wait = 300; !this.closed; wait = Math.min(wait * 2, 5000)) {
|
||||||
|
let r = null;
|
||||||
|
try {
|
||||||
|
r = await fetch(base + this.sid + '/in', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||||
|
} catch (e) { /* no answer: send it again */ }
|
||||||
|
if (r && r.status < 500) break;
|
||||||
|
await new Promise(res => setTimeout(res, wait));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.inBusy = false;
|
||||||
|
}
|
||||||
|
send(data) { this.queueIn({ t: 'i', text: data }); }
|
||||||
|
fit() {
|
||||||
|
const b = this.el.querySelector('.pane-body');
|
||||||
|
if (b.clientWidth > 20 && b.clientHeight > 20) { try { this.fitter.fit(); } catch (e) { /* not shown yet */ } }
|
||||||
|
}
|
||||||
|
close() {
|
||||||
|
if (this.closed) return;
|
||||||
|
this.closed = true;
|
||||||
|
if (this.ws) { try { this.ws.close(); } catch (e) { /* closed already */ } }
|
||||||
|
if (this.sid) fetch(base + this.sid + '/close', { method: 'POST', keepalive: true }).catch(() => {});
|
||||||
|
this.term.dispose();
|
||||||
|
this.el.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// who is what a shell is signed in as, for titles.
|
||||||
|
function who(s) {
|
||||||
|
return s.ssh ? s.ssh.user + '@' + s.ssh.host + (s.ssh.port && s.ssh.port !== 22 ? ':' + s.ssh.port : '') : s.agent.user + '@' + s.agent.host;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- tabs ----------
|
||||||
|
const tabs = [];
|
||||||
|
let active = null, activePane = null;
|
||||||
|
const tabOf = s => tabs.find(t => t.panes.includes(s));
|
||||||
|
|
||||||
|
function makeTab(panes, index) {
|
||||||
|
const t = { title: null, panes: panes, layout: panes.length === 1 ? '1' : 'h', c: 0.5, r: 0.5, gutters: [] };
|
||||||
|
t.view = document.createElement('div');
|
||||||
|
t.view.className = 'tabview';
|
||||||
|
area.insertBefore(t.view, hint);
|
||||||
|
panes.forEach(s => t.view.appendChild(s.el));
|
||||||
|
t.el = document.createElement('div');
|
||||||
|
t.el.className = 'tab enter';
|
||||||
|
t.el.innerHTML = '<span class="dot wait"></span><span class="tab-title"></span>' +
|
||||||
|
'<button type="button" class="tab-x" title="Закрыть вкладку" aria-label="Закрыть вкладку">' + X + '</button>';
|
||||||
|
t.el.addEventListener('click', ev => { if (!ev.target.closest('.tab-x, input') && !dragJustEnded) activate(t); });
|
||||||
|
t.el.querySelector('.tab-x').onclick = ev => { ev.stopPropagation(); closeTab(t); };
|
||||||
|
t.el.querySelector('.tab-title').addEventListener('dblclick', ev => { ev.stopPropagation(); rename(t); });
|
||||||
|
if (desk) t.el.addEventListener('pointerdown', ev => { if (!ev.target.closest('.tab-x, input')) startDrag(ev, 'tab', t); });
|
||||||
|
const at = index == null ? tabs.length : index;
|
||||||
|
tablist.insertBefore(t.el, tabs[at] ? tabs[at].el : mark);
|
||||||
|
tabs.splice(at, 0, t);
|
||||||
|
renderTab(t);
|
||||||
|
fitTabs();
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(() => t.el.classList.remove('enter')));
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
function tabTitle(t) {
|
||||||
|
if (t.title) return t.title;
|
||||||
|
const s = t.panes[0], name = s.ssh ? s.ssh.host : s.agent.name;
|
||||||
|
return t.panes.length > 1 ? name + ' +' + (t.panes.length - 1) : name;
|
||||||
|
}
|
||||||
|
// Tab widths: as wide as the title wants, from a comfortable minimum up to
|
||||||
|
// a maximum; when they do not fit, all give way evenly down to a floor,
|
||||||
|
// titles cut with "…", and below the floor the strip scrolls instead.
|
||||||
|
const ruler = document.createElement('canvas').getContext('2d');
|
||||||
|
const W = touch ? { min: 76, base: 124, max: 180, font: 12 } : { min: 96, base: 180, max: 300, font: 13 };
|
||||||
|
const CHROME = touch ? 52 : 62; // padding, dot, gaps, close button, borders
|
||||||
|
function fitTabs() {
|
||||||
|
ruler.font = W.font + 'px ' + getComputedStyle(document.body).fontFamily;
|
||||||
|
const live = tabs;
|
||||||
|
const want = live.map(t => Math.min(W.max, Math.max(W.base, Math.ceil(ruler.measureText(tabTitle(t)).width) + CHROME + (t.panes.some(p => p.ssh) ? 46 : 0))));
|
||||||
|
const strip = $('tabs');
|
||||||
|
const room = strip.clientWidth - parseFloat(getComputedStyle(strip).paddingLeft) * 2 - addBtn.offsetWidth - 4 * (live.length + 1);
|
||||||
|
let cap = W.max;
|
||||||
|
if (want.reduce((a, b) => a + b, 0) > room) {
|
||||||
|
// The widest give way first, to one width for all at the most.
|
||||||
|
let lo = W.min, hi = W.max;
|
||||||
|
for (let k = 0; k < 20; k++) {
|
||||||
|
const mid = (lo + hi) / 2;
|
||||||
|
if (want.reduce((a, b) => a + Math.min(b, mid), 0) > room) hi = mid; else lo = mid;
|
||||||
|
}
|
||||||
|
cap = Math.max(W.min, Math.floor(lo));
|
||||||
|
}
|
||||||
|
live.forEach((t, i) => { t.el.style.width = Math.min(want[i], cap) + 'px'; });
|
||||||
|
}
|
||||||
|
window.addEventListener('resize', () => fitTabs());
|
||||||
|
|
||||||
|
function renderTab(t) {
|
||||||
|
t.el.querySelector('.tab-title').textContent = tabTitle(t);
|
||||||
|
const ssh = t.panes.some(s => s.ssh);
|
||||||
|
if (ssh !== !!t.el.querySelector('.badge-ssh')) {
|
||||||
|
if (ssh) t.el.querySelector('.dot').insertAdjacentHTML('afterend', '<span class="badge-ssh">SSH</span>');
|
||||||
|
else t.el.querySelector('.badge-ssh').remove();
|
||||||
|
}
|
||||||
|
requestAnimationFrame(fitTabs);
|
||||||
|
t.el.title = t.panes.map(who).join(', ');
|
||||||
|
// The tab's dot: red if any shell failed, amber while one connects.
|
||||||
|
const st = t.panes.map(s => s.state);
|
||||||
|
t.el.querySelector('.dot').className = 'dot ' + (st.includes('err') ? 'err' : st.includes('wait') ? 'wait' : st.includes('ok') ? 'ok' : 'off');
|
||||||
|
t.el.classList.toggle('on', t === active);
|
||||||
|
t.view.classList.toggle('split', t.panes.length > 1);
|
||||||
|
syncFloat();
|
||||||
|
}
|
||||||
|
function activate(t) {
|
||||||
|
if (!t) return;
|
||||||
|
active = t;
|
||||||
|
tabs.forEach(x => { x.view.classList.toggle('on', x === t); renderTab(x); });
|
||||||
|
if (!t.panes.includes(activePane)) setActivePane(t.panes[0]);
|
||||||
|
layout(t, false);
|
||||||
|
t.el.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'smooth' });
|
||||||
|
if (desk && activePane) activePane.term.focus();
|
||||||
|
updateMode();
|
||||||
|
}
|
||||||
|
function setActivePane(s) {
|
||||||
|
activePane = s;
|
||||||
|
document.querySelectorAll('.pane.active').forEach(p => p.classList.toggle('active', p === s.el));
|
||||||
|
s.el.classList.add('active');
|
||||||
|
showStatus();
|
||||||
|
}
|
||||||
|
function showStatus() {
|
||||||
|
if (!activePane) { statusEl.textContent = ''; return; }
|
||||||
|
statusEl.textContent = activePane.text;
|
||||||
|
statusEl.title = activePane.text;
|
||||||
|
statusEl.className = activePane.state === 'ok' ? 'st-ok' : activePane.state === 'err' ? 'st-err' : 'muted';
|
||||||
|
}
|
||||||
|
// The corner "+" moves down out of the way of a split tab's title bars.
|
||||||
|
function syncFloat() {
|
||||||
|
document.body.classList.toggle('split1', tabs.length === 1 && tabs[0].panes.length > 1);
|
||||||
|
}
|
||||||
|
// One tab: a plain terminal, the strip folded into the corner "+".
|
||||||
|
function updateMode() {
|
||||||
|
syncFloat();
|
||||||
|
const multi = tabs.length > 1;
|
||||||
|
if (document.body.classList.contains('multi') === multi) return;
|
||||||
|
document.body.classList.toggle('multi', multi);
|
||||||
|
setTimeout(() => active && layout(active, false), 280);
|
||||||
|
}
|
||||||
|
function openTerminal(agent, ssh) {
|
||||||
|
$('empty').hidden = true;
|
||||||
|
const t = makeTab([new Session(agent, ssh)]);
|
||||||
|
activate(t);
|
||||||
|
}
|
||||||
|
function closeTab(t) {
|
||||||
|
const i = tabs.indexOf(t);
|
||||||
|
if (i < 0) return;
|
||||||
|
if (t.panes.length > 1 && !confirm('Закрыть вкладку с ' + t.panes.length + ' терминалами?')) return;
|
||||||
|
tabs.splice(i, 1);
|
||||||
|
t.panes.forEach(s => s.close());
|
||||||
|
t.el.classList.add('leave');
|
||||||
|
t.view.classList.remove('on');
|
||||||
|
setTimeout(() => { t.el.remove(); t.view.remove(); }, 220);
|
||||||
|
if (active === t) {
|
||||||
|
active = null;
|
||||||
|
activate(tabs[Math.min(i, tabs.length - 1)]);
|
||||||
|
}
|
||||||
|
if (!tabs.length) { activePane = null; showStatus(); $('empty').hidden = false; }
|
||||||
|
updateMode();
|
||||||
|
}
|
||||||
|
function rename(t) {
|
||||||
|
const span = t.el.querySelector('.tab-title');
|
||||||
|
const inp = document.createElement('input');
|
||||||
|
inp.className = 'tab-edit';
|
||||||
|
inp.value = tabTitle(t);
|
||||||
|
inp.size = Math.max(6, inp.value.length);
|
||||||
|
span.replaceWith(inp);
|
||||||
|
inp.focus(); inp.select();
|
||||||
|
let done = false;
|
||||||
|
const end = save => {
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
if (save) t.title = inp.value.trim() || null;
|
||||||
|
inp.replaceWith(span);
|
||||||
|
renderTab(t);
|
||||||
|
};
|
||||||
|
inp.onkeydown = ev => { if (ev.key === 'Enter') end(true); else if (ev.key === 'Escape') end(false); ev.stopPropagation(); };
|
||||||
|
inp.onblur = () => end(true);
|
||||||
|
inp.onpointerdown = ev => ev.stopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- splits ----------
|
||||||
|
// Where each terminal of a tab goes, and its gutters, for a layout:
|
||||||
|
// 1, h (side by side), v (one above the other), top2 (two over one),
|
||||||
|
// bottom2 (one over two), grid (two over two). c and r are where the
|
||||||
|
// column and the row divide.
|
||||||
|
function rects(layout, c, r, W, H) {
|
||||||
|
const g = GAP, cw = Math.round((W - g) * c), rh = Math.round((H - g) * r);
|
||||||
|
const top = (y, h) => [{ x: 0, y, w: cw, h }, { x: cw + g, y, w: W - cw - g, h }];
|
||||||
|
const colG = (y, h) => ({ k: 'c', x: cw, y, w: g, h });
|
||||||
|
const rowG = { k: 'r', x: 0, y: rh, w: W, h: g };
|
||||||
|
const bottomY = rh + g, bottomH = H - rh - g;
|
||||||
|
switch (layout) {
|
||||||
|
case 'h': return { panes: top(0, H), gutters: [colG(0, H)] };
|
||||||
|
case 'v': return { panes: [{ x: 0, y: 0, w: W, h: rh }, { x: 0, y: bottomY, w: W, h: bottomH }], gutters: [rowG] };
|
||||||
|
case 'top2': return { panes: top(0, rh).concat([{ x: 0, y: bottomY, w: W, h: bottomH }]), gutters: [colG(0, rh), rowG] };
|
||||||
|
case 'bottom2': return { panes: [{ x: 0, y: 0, w: W, h: rh }].concat(top(bottomY, bottomH)), gutters: [colG(bottomY, bottomH), rowG] };
|
||||||
|
case 'grid': return { panes: top(0, rh).concat(top(bottomY, bottomH)), gutters: [colG(0, H), rowG] };
|
||||||
|
default: return { panes: [{ x: 0, y: 0, w: W, h: H }], gutters: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function layout(t, animate) {
|
||||||
|
const W = t.view.clientWidth, H = t.view.clientHeight;
|
||||||
|
if (!W || !H) return;
|
||||||
|
const L = rects(t.layout, t.c, t.r, W, H);
|
||||||
|
t.view.classList.toggle('instant', !animate);
|
||||||
|
t.panes.forEach((s, i) => {
|
||||||
|
const p = L.panes[i];
|
||||||
|
s.el.style.left = p.x + 'px'; s.el.style.top = p.y + 'px';
|
||||||
|
s.el.style.width = p.w + 'px'; s.el.style.height = p.h + 'px';
|
||||||
|
});
|
||||||
|
while (t.gutters.length < L.gutters.length) {
|
||||||
|
const g = document.createElement('div');
|
||||||
|
g.className = 'gutter';
|
||||||
|
g.addEventListener('pointerdown', ev => startResize(ev, t, g));
|
||||||
|
t.view.appendChild(g);
|
||||||
|
t.gutters.push(g);
|
||||||
|
}
|
||||||
|
t.gutters.forEach((g, i) => {
|
||||||
|
const d = L.gutters[i];
|
||||||
|
g.hidden = !d;
|
||||||
|
if (!d) return;
|
||||||
|
g.className = 'gutter ' + d.k;
|
||||||
|
g.style.left = d.x + 'px'; g.style.top = d.y + 'px'; g.style.width = d.w + 'px'; g.style.height = d.h + 'px';
|
||||||
|
});
|
||||||
|
clearTimeout(t.fitTimer);
|
||||||
|
t.fitTimer = setTimeout(() => t.panes.forEach(s => s.fit()), animate ? 270 : 0);
|
||||||
|
}
|
||||||
|
function startResize(ev, t, g) {
|
||||||
|
ev.preventDefault();
|
||||||
|
g.setPointerCapture(ev.pointerId);
|
||||||
|
const box = t.view.getBoundingClientRect(), k = g.classList.contains('c') ? 'c' : 'r';
|
||||||
|
g.classList.add('drag');
|
||||||
|
let raf = 0;
|
||||||
|
const move = e => {
|
||||||
|
const v = k === 'c' ? (e.clientX - box.left) / box.width : (e.clientY - box.top) / box.height;
|
||||||
|
t[k] = Math.min(0.85, Math.max(0.15, v));
|
||||||
|
if (!raf) raf = requestAnimationFrame(() => { raf = 0; layout(t, false); });
|
||||||
|
};
|
||||||
|
const up = () => {
|
||||||
|
g.classList.remove('drag');
|
||||||
|
g.removeEventListener('pointermove', move);
|
||||||
|
g.removeEventListener('pointerup', up);
|
||||||
|
layout(t, false);
|
||||||
|
};
|
||||||
|
g.addEventListener('pointermove', move);
|
||||||
|
g.addEventListener('pointerup', up);
|
||||||
|
}
|
||||||
|
// A layout with one terminal fewer, the others keeping their places.
|
||||||
|
function withoutPane(layout, i) {
|
||||||
|
if (layout === 'h' || layout === 'v') return '1';
|
||||||
|
if (layout === 'top2') return i === 2 ? 'h' : 'v';
|
||||||
|
if (layout === 'bottom2') return i === 0 ? 'h' : 'v';
|
||||||
|
if (layout === 'grid') return i < 2 ? 'bottom2' : 'top2';
|
||||||
|
return '1';
|
||||||
|
}
|
||||||
|
function takePane(t, s) {
|
||||||
|
const i = t.panes.indexOf(s);
|
||||||
|
t.layout = withoutPane(t.layout, i);
|
||||||
|
t.panes.splice(i, 1);
|
||||||
|
}
|
||||||
|
function closePane(s) {
|
||||||
|
const t = tabOf(s);
|
||||||
|
if (!t) return;
|
||||||
|
if (t.panes.length === 1) { closeTab(t); return; }
|
||||||
|
takePane(t, s);
|
||||||
|
s.close();
|
||||||
|
if (activePane === s) setActivePane(t.panes[0]);
|
||||||
|
renderTab(t);
|
||||||
|
layout(t, true);
|
||||||
|
}
|
||||||
|
// Where a terminal dropped on tab t at (x, y) would go: the new layout,
|
||||||
|
// the order of its terminals ('P' the new one), and its place.
|
||||||
|
const SIDE = { left: 'слева', right: 'справа', top: 'сверху', bottom: 'снизу' };
|
||||||
|
function mergePlan(t, x, y) {
|
||||||
|
const n = t.panes.length, W = t.view.clientWidth, H = t.view.clientHeight;
|
||||||
|
const fx = x / W, fy = y / H;
|
||||||
|
let lay, order, label;
|
||||||
|
if (n === 1) {
|
||||||
|
const d = { left: fx, right: 1 - fx, top: fy, bottom: 1 - fy };
|
||||||
|
const k = Object.keys(d).reduce((a, b) => d[a] <= d[b] ? a : b);
|
||||||
|
lay = k === 'left' || k === 'right' ? 'h' : 'v';
|
||||||
|
order = k === 'left' || k === 'top' ? ['P', 0] : [0, 'P'];
|
||||||
|
label = SIDE[k];
|
||||||
|
} else if (n === 2 && t.layout === 'h') {
|
||||||
|
if (fy < 0.5) { lay = 'bottom2'; order = ['P', 0, 1]; label = 'сверху'; }
|
||||||
|
else { lay = 'top2'; order = [0, 1, 'P']; label = 'снизу'; }
|
||||||
|
} else if (n === 2) {
|
||||||
|
if (fy < 0.5) { lay = 'top2'; order = fx < 0.5 ? ['P', 0, 1] : [0, 'P', 1]; }
|
||||||
|
else { lay = 'bottom2'; order = fx < 0.5 ? [0, 'P', 1] : [0, 1, 'P']; }
|
||||||
|
label = (fy < 0.5 ? 'сверху ' : 'снизу ') + (fx < 0.5 ? 'слева' : 'справа');
|
||||||
|
} else if (n === 3) {
|
||||||
|
lay = 'grid';
|
||||||
|
if (t.layout === 'top2') { order = fx < 0.5 ? [0, 1, 'P', 2] : [0, 1, 2, 'P']; label = 'снизу ' + (fx < 0.5 ? 'слева' : 'справа'); }
|
||||||
|
else { order = fx < 0.5 ? ['P', 0, 1, 2] : [0, 'P', 1, 2]; label = 'сверху ' + (fx < 0.5 ? 'слева' : 'справа'); }
|
||||||
|
} else return null;
|
||||||
|
return { layout: lay, order, label, rect: rects(lay, t.c, t.r, W, H).panes[order.indexOf('P')] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- drag and drop ----------
|
||||||
|
// A tab: along the strip to reorder it, or onto the current tab to join
|
||||||
|
// it. A terminal from a split, by its title bar: to the strip to make it a
|
||||||
|
// tab of its own, or onto another terminal of its tab to swap places.
|
||||||
|
let drag = null, dragJustEnded = false;
|
||||||
|
function startDrag(ev, kind, obj) {
|
||||||
|
if (ev.button !== 0) return;
|
||||||
|
drag = { kind, obj, x0: ev.clientX, y0: ev.clientY, on: false, id: ev.pointerId };
|
||||||
|
}
|
||||||
|
function beginDrag() {
|
||||||
|
drag.on = true;
|
||||||
|
const src = drag.kind === 'tab' ? drag.obj.el : drag.obj.el.querySelector('.pane-head');
|
||||||
|
const g = document.createElement('div');
|
||||||
|
g.className = 'drag-ghost ' + drag.kind;
|
||||||
|
g.textContent = drag.kind === 'tab' ? tabTitle(drag.obj) : drag.obj.agent.user + '@' + drag.obj.agent.host;
|
||||||
|
document.body.appendChild(g);
|
||||||
|
drag.ghost = g;
|
||||||
|
drag.src = src;
|
||||||
|
src.classList.add('dragging');
|
||||||
|
document.body.classList.add('dnd');
|
||||||
|
// Pulling a terminal out: the strip shows, one tab or not, to drop it on.
|
||||||
|
if (drag.kind === 'pane') document.body.classList.add('multi-hint');
|
||||||
|
}
|
||||||
|
function stripIndex(x) {
|
||||||
|
const others = tabs.filter(t => !(drag.kind === 'tab' && t === drag.obj));
|
||||||
|
let i = others.findIndex(t => { const r = t.el.getBoundingClientRect(); return x < r.left + r.width / 2; });
|
||||||
|
if (i < 0) i = others.length;
|
||||||
|
const ref = others[i] ? others[i].el.getBoundingClientRect() : (others.length ? others[others.length - 1].el.getBoundingClientRect() : null);
|
||||||
|
const lr = tablist.getBoundingClientRect();
|
||||||
|
const mx = others[i] ? ref.left - 3 : ref ? ref.right + 1 : lr.left;
|
||||||
|
return { i, others, mx: mx - lr.left + tablist.scrollLeft };
|
||||||
|
}
|
||||||
|
function showHint(r, label, bad) {
|
||||||
|
hint.hidden = false;
|
||||||
|
hint.classList.toggle('bad', !!bad);
|
||||||
|
const v = active.view;
|
||||||
|
hint.style.left = (v.offsetLeft + r.x) + 'px'; hint.style.top = (v.offsetTop + r.y) + 'px';
|
||||||
|
hint.style.width = r.w + 'px'; hint.style.height = r.h + 'px';
|
||||||
|
hint.textContent = label;
|
||||||
|
}
|
||||||
|
function onDragMove(ev) {
|
||||||
|
if (!drag || ev.pointerId !== drag.id) return;
|
||||||
|
if (!drag.on) {
|
||||||
|
if (Math.hypot(ev.clientX - drag.x0, ev.clientY - drag.y0) < 6) return;
|
||||||
|
beginDrag();
|
||||||
|
}
|
||||||
|
drag.ghost.style.transform = 'translate(' + (ev.clientX + 10) + 'px,' + (ev.clientY + 8) + 'px)';
|
||||||
|
drag.target = null;
|
||||||
|
hint.hidden = true; mark.hidden = true;
|
||||||
|
const strip = $('tabs').getBoundingClientRect();
|
||||||
|
const stripShown = document.body.classList.contains('multi') || document.body.classList.contains('multi-hint');
|
||||||
|
const inStrip = stripShown && ev.clientY >= strip.top - 6 && ev.clientY <= strip.bottom + 10;
|
||||||
|
const inArea = active && (() => { const r = active.view.getBoundingClientRect(); return ev.clientX >= r.left && ev.clientX <= r.right && ev.clientY >= r.top && ev.clientY <= r.bottom; })();
|
||||||
|
if (inStrip) {
|
||||||
|
const s = stripIndex(ev.clientX);
|
||||||
|
mark.hidden = false; mark.style.left = s.mx + 'px';
|
||||||
|
drag.target = { kind: 'strip', index: s.i, others: s.others };
|
||||||
|
drag.ghost.dataset.hint = drag.kind === 'tab' ? 'переставить' : 'отдельной вкладкой';
|
||||||
|
} else if (inArea && drag.kind === 'tab' && drag.obj !== active) {
|
||||||
|
const r = active.view.getBoundingClientRect();
|
||||||
|
if (drag.obj.panes.length > 1) {
|
||||||
|
showHint({ x: 0, y: 0, w: r.width, h: r.height }, 'вкладку с несколькими терминалами не объединить', true);
|
||||||
|
} else if (active.panes.length >= 4) {
|
||||||
|
showHint({ x: 0, y: 0, w: r.width, h: r.height }, 'в одной вкладке не больше 4 терминалов', true);
|
||||||
|
} else {
|
||||||
|
const plan = mergePlan(active, ev.clientX - r.left, ev.clientY - r.top);
|
||||||
|
showHint(plan.rect, plan.label);
|
||||||
|
drag.target = { kind: 'merge', plan };
|
||||||
|
}
|
||||||
|
drag.ghost.dataset.hint = '';
|
||||||
|
} else if (inArea && drag.kind === 'pane' && tabOf(drag.obj) === active) {
|
||||||
|
const r = active.view.getBoundingClientRect();
|
||||||
|
const over = active.panes.find(s => { const p = s.el.getBoundingClientRect(); return s !== drag.obj && ev.clientX >= p.left && ev.clientX <= p.right && ev.clientY >= p.top && ev.clientY <= p.bottom; });
|
||||||
|
if (over) {
|
||||||
|
const p = over.el.getBoundingClientRect();
|
||||||
|
showHint({ x: p.left - r.left, y: p.top - r.top, w: p.width, h: p.height }, 'поменять местами');
|
||||||
|
drag.target = { kind: 'swap', with: over };
|
||||||
|
}
|
||||||
|
drag.ghost.dataset.hint = over ? '' : 'к вкладкам - отдельной вкладкой';
|
||||||
|
} else {
|
||||||
|
drag.ghost.dataset.hint = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onDragEnd(ev) {
|
||||||
|
if (!drag || ev.pointerId !== drag.id) return;
|
||||||
|
const d = drag;
|
||||||
|
drag = null;
|
||||||
|
if (!d.on) return;
|
||||||
|
d.ghost.remove();
|
||||||
|
d.src.classList.remove('dragging');
|
||||||
|
document.body.classList.remove('dnd', 'multi-hint');
|
||||||
|
hint.hidden = true; mark.hidden = true;
|
||||||
|
dragJustEnded = true;
|
||||||
|
setTimeout(() => { dragJustEnded = false; }, 0);
|
||||||
|
const tg = d.target;
|
||||||
|
if (!tg) return;
|
||||||
|
if (d.kind === 'tab' && tg.kind === 'strip') {
|
||||||
|
const t = d.obj, ref = tg.others[tg.index];
|
||||||
|
tabs.splice(tabs.indexOf(t), 1);
|
||||||
|
tabs.splice(ref ? tabs.indexOf(ref) : tabs.length, 0, t);
|
||||||
|
tablist.insertBefore(t.el, ref ? ref.el : mark);
|
||||||
|
} else if (d.kind === 'tab' && tg.kind === 'merge') {
|
||||||
|
const src = d.obj, s = src.panes[0], t = active, plan = tg.plan;
|
||||||
|
// The joining terminal starts where the hint showed it.
|
||||||
|
s.el.classList.add('instant');
|
||||||
|
s.el.style.left = plan.rect.x + 'px'; s.el.style.top = plan.rect.y + 'px';
|
||||||
|
s.el.style.width = plan.rect.w + 'px'; s.el.style.height = plan.rect.h + 'px';
|
||||||
|
t.view.appendChild(s.el);
|
||||||
|
requestAnimationFrame(() => s.el.classList.remove('instant'));
|
||||||
|
t.panes = plan.order.map(o => o === 'P' ? s : t.panes[o]);
|
||||||
|
t.layout = plan.layout;
|
||||||
|
tabs.splice(tabs.indexOf(src), 1);
|
||||||
|
src.el.classList.add('leave');
|
||||||
|
setTimeout(() => { src.el.remove(); src.view.remove(); }, 220);
|
||||||
|
setActivePane(s);
|
||||||
|
renderTab(t);
|
||||||
|
layout(t, true);
|
||||||
|
updateMode();
|
||||||
|
s.term.focus();
|
||||||
|
} else if (d.kind === 'pane' && tg.kind === 'strip') {
|
||||||
|
const s = d.obj, from = tabOf(s), ref = tg.others[tg.index];
|
||||||
|
takePane(from, s);
|
||||||
|
renderTab(from);
|
||||||
|
const t = makeTab([s], ref ? tabs.indexOf(ref) : tabs.length);
|
||||||
|
layout(from, true);
|
||||||
|
activate(t);
|
||||||
|
} else if (d.kind === 'pane' && tg.kind === 'swap') {
|
||||||
|
const t = active, a = t.panes.indexOf(d.obj), b = t.panes.indexOf(tg.with);
|
||||||
|
[t.panes[a], t.panes[b]] = [t.panes[b], t.panes[a]];
|
||||||
|
layout(t, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('pointermove', onDragMove);
|
||||||
|
window.addEventListener('pointerup', onDragEnd);
|
||||||
|
window.addEventListener('pointercancel', onDragEnd);
|
||||||
|
|
||||||
|
// ---------- the "+" menu ----------
|
||||||
|
let agentsCache = null;
|
||||||
|
function openMenu(anchor) {
|
||||||
|
const here = (activePane && activePane.agent) || PAGE.agent;
|
||||||
|
addMenu.innerHTML = '';
|
||||||
|
const item = (html, fn, cls) => {
|
||||||
|
const b = document.createElement('button');
|
||||||
|
b.type = 'button'; b.className = 'item ' + (cls || ''); b.innerHTML = html;
|
||||||
|
b.onclick = fn;
|
||||||
|
addMenu.appendChild(b);
|
||||||
|
return b;
|
||||||
|
};
|
||||||
|
const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||||
|
item('<span class="ico">+</span><span><b>Новый терминал здесь</b><small>' + esc(here.name) + ' · ' + esc(here.user + '@' + here.host) + '</small></span>',
|
||||||
|
() => { closeMenu(); openTerminal(here); });
|
||||||
|
item('<span class="ico">' + KEYI + '</span><span><b>SSH-подключение</b><small>агент откроет SSH к другому хосту</small></span>',
|
||||||
|
() => { closeMenu(); openSSH(here); });
|
||||||
|
const other = item('<span class="ico">⇄</span><span><b>На другой машине</b><small>выбрать из списка</small></span><span class="chev">›</span>', () => {
|
||||||
|
other.remove();
|
||||||
|
const list = document.createElement('div');
|
||||||
|
list.className = 'mlist';
|
||||||
|
list.innerHTML = '<div class="muted" style="padding:8px 10px;font-size:13px">загрузка…</div>';
|
||||||
|
addMenu.appendChild(list);
|
||||||
|
(agentsCache ? Promise.resolve(agentsCache) : fetch('/api/panel/agents/presence', { cache: 'no-store' }).then(r => r.json()))
|
||||||
|
.then(all => {
|
||||||
|
agentsCache = all;
|
||||||
|
list.innerHTML = '';
|
||||||
|
all.forEach(a => {
|
||||||
|
const b = document.createElement('button');
|
||||||
|
b.type = 'button'; b.className = 'item machine'; b.disabled = !a.online;
|
||||||
|
b.innerHTML = '<span class="dot ' + (a.online ? 'ok' : 'off') + '"></span><span><b>' + esc(a.name) + '</b><small>' +
|
||||||
|
esc(a.user + '@' + a.hostname) + (a.online ? '' : ' · ' + esc(a.seen)) + '</small></span>';
|
||||||
|
b.onclick = () => { closeMenu(); openTerminal({ id: a.id, name: a.name, host: a.hostname, user: a.user }); };
|
||||||
|
list.appendChild(b);
|
||||||
|
});
|
||||||
|
if (!all.length) list.innerHTML = '<div class="muted" style="padding:8px 10px">машин нет</div>';
|
||||||
|
placeMenu(anchor);
|
||||||
|
})
|
||||||
|
.catch(() => { list.innerHTML = '<div class="muted" style="padding:8px 10px">не загрузилось</div>'; });
|
||||||
|
});
|
||||||
|
addMenu.hidden = false;
|
||||||
|
placeMenu(anchor);
|
||||||
|
setTimeout(() => document.addEventListener('pointerdown', outside, true), 0);
|
||||||
|
}
|
||||||
|
function placeMenu(anchor) {
|
||||||
|
const r = anchor.getBoundingClientRect(), m = addMenu.getBoundingClientRect();
|
||||||
|
addMenu.style.left = Math.max(8, Math.min(innerWidth - m.width - 8, r.right - m.width)) + 'px';
|
||||||
|
addMenu.style.top = Math.min(innerHeight - m.height - 8, r.bottom + 6) + 'px';
|
||||||
|
}
|
||||||
|
function outside(ev) { if (!addMenu.contains(ev.target)) closeMenu(); }
|
||||||
|
function closeMenu() {
|
||||||
|
addMenu.hidden = true;
|
||||||
|
agentsCache = null;
|
||||||
|
document.removeEventListener('pointerdown', outside, true);
|
||||||
|
}
|
||||||
|
addBtn.onclick = () => openMenu(addBtn);
|
||||||
|
floatAdd.onclick = () => openMenu(floatAdd);
|
||||||
|
$('emptyadd').onclick = ev => openMenu(ev.currentTarget);
|
||||||
|
document.addEventListener('keydown', ev => { if (ev.key === 'Escape' && !addMenu.hidden) closeMenu(); });
|
||||||
|
|
||||||
|
// ---------- the SSH dialog ----------
|
||||||
|
const KEYI = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="7.5" cy="15.5" r="4.5"/><path d="m21 2-9.6 9.6M15.5 7.5l3 3L22 7l-3-3"/></svg>';
|
||||||
|
let sshDlg = null;
|
||||||
|
function openSSH(here) {
|
||||||
|
if (!sshDlg) sshDlg = makeSSHDialog();
|
||||||
|
sshDlg.show(here);
|
||||||
|
}
|
||||||
|
function makeSSHDialog() {
|
||||||
|
const d = document.createElement('dialog');
|
||||||
|
d.className = 'sshdlg';
|
||||||
|
d.innerHTML =
|
||||||
|
'<form method="dialog" autocomplete="off">' +
|
||||||
|
'<div class="dlg-h"><span class="ico">' + KEYI + '</span><strong style="flex:1">SSH-подключение</strong>' +
|
||||||
|
'<button type="button" class="btn ghost small icon close x" data-close aria-label="Закрыть">' + X + '</button></div>' +
|
||||||
|
'<div class="dlg-b">' +
|
||||||
|
'<label class="f"><span>Подключаться с машины</span><select name="agent"></select></label>' +
|
||||||
|
'<div class="frow"><label class="f grow"><span>Хост</span><input type="text" name="host" placeholder="example.com или 10.0.0.5" required autocapitalize="off" spellcheck="false"></label>' +
|
||||||
|
'<label class="f port"><span>Порт</span><input type="number" name="port" min="1" max="65535" value="22" required></label></div>' +
|
||||||
|
'<label class="f"><span>Пользователь</span><input type="text" name="user" placeholder="root" required autocapitalize="off" spellcheck="false"></label>' +
|
||||||
|
'<div class="f"><span>Вход</span><div class="seg" role="tablist">' +
|
||||||
|
'<button type="button" data-auth="password" class="on">Пароль</button><button type="button" data-auth="key">Ключ</button><button type="button" data-auth="agent">Ключи агента</button></div></div>' +
|
||||||
|
'<div class="auth" data-for="password"><input type="password" name="password" placeholder="пароль" autocomplete="off"></div>' +
|
||||||
|
'<div class="auth" data-for="key" hidden>' +
|
||||||
|
'<textarea name="key" rows="5" spellcheck="false" placeholder="-----BEGIN OPENSSH PRIVATE KEY----- …вставьте ключ или загрузите файл"></textarea>' +
|
||||||
|
'<div class="frow"><button type="button" class="btn ghost small" data-load>Загрузить файл…</button><span class="muted keyname"></span>' +
|
||||||
|
'<input type="file" hidden></div>' +
|
||||||
|
'<input type="password" name="passphrase" placeholder="фраза ключа, если есть" autocomplete="off"></div>' +
|
||||||
|
'<p class="auth muted" data-for="agent" hidden>Агент войдёт своими ключами из ~/.ssh (id_ed25519, id_ecdsa, id_rsa) без фразы.</p>' +
|
||||||
|
'<p class="sshmsg"></p>' +
|
||||||
|
'<div class="row end"><button type="button" class="btn ghost" data-close>Отмена</button><button type="submit">Подключиться</button></div>' +
|
||||||
|
'</div></form>';
|
||||||
|
document.body.appendChild(d);
|
||||||
|
const f = d.querySelector('form'), msg = d.querySelector('.sshmsg');
|
||||||
|
let auth = 'password';
|
||||||
|
const setAuth = a => {
|
||||||
|
auth = a;
|
||||||
|
d.querySelectorAll('.seg button').forEach(b => b.classList.toggle('on', b.dataset.auth === a));
|
||||||
|
d.querySelectorAll('.auth').forEach(el => { el.hidden = el.dataset.for !== a; });
|
||||||
|
};
|
||||||
|
d.querySelectorAll('.seg button').forEach(b => { b.onclick = () => setAuth(b.dataset.auth); });
|
||||||
|
d.querySelectorAll('[data-close]').forEach(b => { b.onclick = () => close(); });
|
||||||
|
const file = d.querySelector('input[type=file]');
|
||||||
|
d.querySelector('[data-load]').onclick = () => file.click();
|
||||||
|
file.onchange = () => {
|
||||||
|
const fl = file.files[0];
|
||||||
|
if (!fl) return;
|
||||||
|
if (fl.size > 32768) { msg.textContent = 'файл великоват для ключа'; return; }
|
||||||
|
fl.text().then(t => { f.key.value = t.trim() + '\n'; d.querySelector('.keyname').textContent = fl.name; msg.textContent = ''; });
|
||||||
|
file.value = '';
|
||||||
|
};
|
||||||
|
// then runs once the dialog is shut and focus is back where it was, so
|
||||||
|
// what it focuses stays focused.
|
||||||
|
function close(then) {
|
||||||
|
d.close();
|
||||||
|
if (then) then();
|
||||||
|
}
|
||||||
|
d.addEventListener('cancel', ev => { ev.preventDefault(); close(); });
|
||||||
|
d.addEventListener('click', ev => { if (ev.target === d) close(); });
|
||||||
|
let agents = [];
|
||||||
|
f.onsubmit = ev => {
|
||||||
|
ev.preventDefault();
|
||||||
|
const a = agents.find(x => x.id === f.agent.value);
|
||||||
|
if (!a) { msg.textContent = 'выберите машину'; return; }
|
||||||
|
const t = { host: f.host.value.trim(), port: Number(f.port.value) || 22, user: f.user.value.trim() };
|
||||||
|
if (auth === 'password') {
|
||||||
|
if (!f.password.value) { msg.textContent = 'введите пароль'; f.password.focus(); return; }
|
||||||
|
t.password = f.password.value;
|
||||||
|
} else if (auth === 'key') {
|
||||||
|
if (!/PRIVATE KEY/.test(f.key.value)) { msg.textContent = 'вставьте закрытый ключ или загрузите файл'; return; }
|
||||||
|
t.key = f.key.value;
|
||||||
|
if (f.passphrase.value) t.passphrase = f.passphrase.value;
|
||||||
|
}
|
||||||
|
try { localStorage.setItem('lpra.ssh.last', JSON.stringify({ agent: a.id, host: t.host, port: t.port, user: t.user, auth: auth })); } catch (e) { /* not kept */ }
|
||||||
|
f.password.value = ''; f.passphrase.value = ''; f.key.value = '';
|
||||||
|
d.querySelector('.keyname').textContent = '';
|
||||||
|
close(() => openTerminal(a, t));
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
show(here) {
|
||||||
|
msg.textContent = '';
|
||||||
|
let last = {};
|
||||||
|
try { last = JSON.parse(localStorage.getItem('lpra.ssh.last') || '{}'); } catch (e) { /* none */ }
|
||||||
|
agents = [here];
|
||||||
|
const fill = () => {
|
||||||
|
f.agent.innerHTML = '';
|
||||||
|
agents.forEach(a => {
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = a.id; o.textContent = a.name + ' · ' + a.user + '@' + a.host + (a.online === false ? ' (не в сети)' : '');
|
||||||
|
o.disabled = a.online === false;
|
||||||
|
f.agent.appendChild(o);
|
||||||
|
});
|
||||||
|
f.agent.value = agents.some(a => a.id === last.agent && a.online !== false) ? last.agent : here.id;
|
||||||
|
};
|
||||||
|
fill();
|
||||||
|
fetch('/api/panel/agents/presence', { cache: 'no-store' }).then(r => r.json()).then(all => {
|
||||||
|
agents = all.map(a => ({ id: a.id, name: a.name, host: a.hostname, user: a.user, online: a.online }));
|
||||||
|
if (!agents.some(a => a.id === here.id)) agents.unshift(here);
|
||||||
|
fill();
|
||||||
|
}).catch(() => {});
|
||||||
|
if (last.host) { f.host.value = last.host; f.port.value = last.port || 22; f.user.value = last.user || ''; }
|
||||||
|
setAuth(last.auth === 'key' || last.auth === 'agent' ? last.auth : 'password');
|
||||||
|
d.showModal();
|
||||||
|
// On a phone the keyboard waits for a tap.
|
||||||
|
if (desk) (last.host ? (last.auth === 'password' || !last.auth ? f.password : f.host) : f.host).focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- page ----------
|
||||||
|
function refit() {
|
||||||
|
if (touch && window.visualViewport) {
|
||||||
|
// Follow the visible area, so the extra keys sit right above the
|
||||||
|
// on-screen keyboard.
|
||||||
|
const vv = window.visualViewport;
|
||||||
|
document.body.style.top = vv.offsetTop + 'px';
|
||||||
|
document.body.style.height = vv.height + 'px';
|
||||||
|
}
|
||||||
|
if (active) layout(active, false);
|
||||||
|
}
|
||||||
|
window.addEventListener('resize', refit);
|
||||||
|
if (touch && window.visualViewport) {
|
||||||
|
window.visualViewport.addEventListener('resize', refit);
|
||||||
|
window.visualViewport.addEventListener('scroll', refit);
|
||||||
|
}
|
||||||
|
window.addEventListener('pagehide', () => {
|
||||||
|
tabs.forEach(t => t.panes.forEach(s => { if (s.sid) navigator.sendBeacon(base + s.sid + '/close'); }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// For the touch helpers in the page: the terminal being typed in.
|
||||||
|
window.TermPane = { active: () => activePane };
|
||||||
|
openTerminal(PAGE.agent);
|
||||||
|
refit();
|
||||||
|
})();
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
// The users table in Settings: the role, the machines a user may use (a
|
||||||
|
// dialog with a list to tick), the password sign-in and whether the user
|
||||||
|
// may switch it themselves. Each change is saved at once.
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
var table = document.querySelector('table.users');
|
||||||
|
if (!table) return;
|
||||||
|
var $ = function (id) { return document.getElementById(id); };
|
||||||
|
|
||||||
|
function save(id, body) {
|
||||||
|
return fetch('/api/panel/users/' + id, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)})
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.catch(function () { return {ok: false, error: 'ошибка сети'}; });
|
||||||
|
}
|
||||||
|
function note(tr, text, bad) {
|
||||||
|
var n = tr.querySelector('.u-note');
|
||||||
|
if (!n) {
|
||||||
|
n = document.createElement('div');
|
||||||
|
n.className = 'u-note';
|
||||||
|
tr.cells[0].appendChild(n);
|
||||||
|
}
|
||||||
|
n.textContent = text;
|
||||||
|
n.classList.toggle('bad', !!bad);
|
||||||
|
n.classList.remove('fade');
|
||||||
|
void n.offsetWidth;
|
||||||
|
n.classList.add('fade');
|
||||||
|
}
|
||||||
|
|
||||||
|
table.addEventListener('change', function (ev) {
|
||||||
|
var tr = ev.target.closest('tr'), id = tr && tr.dataset.id, el = ev.target;
|
||||||
|
if (!id) return;
|
||||||
|
var body = {}, undo;
|
||||||
|
if (el.classList.contains('u-role')) {
|
||||||
|
body.role = el.value;
|
||||||
|
undo = function () { el.value = el.value === 'user' ? 'admin' : 'user'; };
|
||||||
|
} else if (el.classList.contains('u-pw')) {
|
||||||
|
body.password_off = !el.checked;
|
||||||
|
undo = function () { el.checked = !el.checked; };
|
||||||
|
} else if (el.classList.contains('u-lock')) {
|
||||||
|
body.pw_locked = el.checked;
|
||||||
|
undo = function () { el.checked = !el.checked; };
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
save(id, body).then(function (r) {
|
||||||
|
if (!r.ok) { undo(); note(tr, r.error || 'не сохранилось', true); return; }
|
||||||
|
note(tr, 'сохранено');
|
||||||
|
if (body.role) {
|
||||||
|
var user = body.role === 'user';
|
||||||
|
tr.querySelector('.u-agents').hidden = !user;
|
||||||
|
tr.querySelector('.u-count').hidden = !user;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- the machines of a user ----
|
||||||
|
var dlg = $('uadlg'), list = $('ualist'), cur = null;
|
||||||
|
function render(filter) {
|
||||||
|
var f = (filter || '').toLowerCase();
|
||||||
|
list.querySelectorAll('label').forEach(function (l) {
|
||||||
|
l.hidden = f && l.textContent.toLowerCase().indexOf(f) < 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
table.addEventListener('click', function (ev) {
|
||||||
|
var b = ev.target.closest('.u-agents');
|
||||||
|
if (!b) return;
|
||||||
|
var tr = b.closest('tr');
|
||||||
|
cur = tr;
|
||||||
|
$('uaname').textContent = tr.dataset.name;
|
||||||
|
list.innerHTML = '<div class="muted">загрузка…</div>';
|
||||||
|
$('uafilter').value = '';
|
||||||
|
dlg.showModal();
|
||||||
|
fetch('/api/panel/users/' + tr.dataset.id + '/agents', {cache: 'no-store'}).then(function (r) { return r.json(); }).then(function (d) {
|
||||||
|
list.innerHTML = '';
|
||||||
|
d.agents.forEach(function (a) {
|
||||||
|
var l = document.createElement('label');
|
||||||
|
l.className = 'ua-item';
|
||||||
|
l.innerHTML = '<input type="checkbox"><span class="dot ' + (a.online ? 'on' : 'off') + '"></span><span class="ua-name"></span><span class="muted ua-host"></span>';
|
||||||
|
l.querySelector('input').value = a.id;
|
||||||
|
l.querySelector('input').checked = a.on;
|
||||||
|
l.querySelector('.ua-name').textContent = a.name;
|
||||||
|
l.querySelector('.ua-host').textContent = a.hostname;
|
||||||
|
list.appendChild(l);
|
||||||
|
});
|
||||||
|
if (!d.agents.length) list.innerHTML = '<div class="muted">машин пока нет</div>';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
$('uafilter').addEventListener('input', function (ev) { render(ev.target.value); });
|
||||||
|
$('uaall').onclick = function () { list.querySelectorAll('label:not([hidden]) input').forEach(function (i) { i.checked = true; }); };
|
||||||
|
$('uanone').onclick = function () { list.querySelectorAll('label:not([hidden]) input').forEach(function (i) { i.checked = false; }); };
|
||||||
|
$('uaclose').onclick = $('uacancel').onclick = function () { dlg.close(); };
|
||||||
|
$('uasave').onclick = function () {
|
||||||
|
var ids = [].map.call(list.querySelectorAll('input:checked'), function (i) { return i.value; });
|
||||||
|
save(cur.dataset.id, {agents: ids}).then(function (r) {
|
||||||
|
if (!r.ok) { note(cur, r.error || 'не сохранилось', true); return; }
|
||||||
|
cur.querySelector('.u-count').textContent = ids.length;
|
||||||
|
note(cur, 'машин: ' + ids.length);
|
||||||
|
dlg.close();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- a new user: the machines appear for a plain user ----
|
||||||
|
var role = $('newrole'), pick = $('newagents');
|
||||||
|
if (role) role.onchange = function () { pick.hidden = role.value !== 'user'; };
|
||||||
|
})();
|
||||||
@@ -4,17 +4,17 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Машины - LPRA</title>
|
<title>Машины - LPRA</title>
|
||||||
<link rel="stylesheet" href="/static/app.css">
|
<link rel="stylesheet" href="{{asset "app.css"}}">
|
||||||
|
<script src="{{asset "pin.js"}}"></script>
|
||||||
|
<script src="{{asset "scroll.js"}}" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div class="brand">LPRA · Удалённый доступ</div>
|
<div class="brand"><a class="home" href="/" title="К списку машин">LPRA</a> · Удалённый доступ</div>
|
||||||
<nav>
|
<nav>
|
||||||
<a href="/">Машины</a>
|
<a href="/">Машины</a>
|
||||||
<a href="/settings">Настройки</a>
|
{{if .IsAdmin}}<a href="/settings">Настройки</a>{{end}}
|
||||||
<form method="post" action="/logout" style="display:inline; margin-left:22px">
|
<button type="button" class="btn ghost icon acct-btn" id="acct" title="Аккаунт" aria-label="Аккаунт"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/></svg></button>
|
||||||
<button class="btn ghost small" type="submit">Выйти</button>
|
|
||||||
</form>
|
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
@@ -48,13 +48,13 @@
|
|||||||
<a class="btn ghost small icon" href="/agents/{{.ID}}/edit" data-tip="Изменить" aria-label="Изменить">
|
<a class="btn ghost small icon" href="/agents/{{.ID}}/edit" data-tip="Изменить" aria-label="Изменить">
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z"/></svg>
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z"/></svg>
|
||||||
</a>
|
</a>
|
||||||
<form method="post" action="/agents/{{.ID}}/delete" style="display:inline"
|
{{if $.IsAdmin}}<form method="post" action="/agents/{{.ID}}/delete" style="display:inline"
|
||||||
onsubmit="return confirm('Удалить машину {{.Name}}? Сервис на ней продолжит работать, но перестанет быть виден. Чтобы снова подключиться - переустановите агента.')">
|
onsubmit="return confirm('Удалить машину {{.Name}}? Сервис на ней продолжит работать, но перестанет быть виден. Чтобы снова подключиться - переустановите агента.')">
|
||||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||||
<button class="btn danger small icon" type="submit" data-tip="Удалить" aria-label="Удалить">
|
<button class="btn danger small icon" type="submit" data-tip="Удалить" aria-label="Удалить">
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>{{end}}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -62,8 +62,9 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{{else}}
|
{{else}}
|
||||||
<p class="muted">Пока нет подключённых машин. Добавьте первую в разделе
|
{{if .IsAdmin}}<p class="muted">Пока нет подключённых машин. Добавьте первую в разделе
|
||||||
<a href="/settings">Настройки</a> - там одна команда для установки.</p>
|
<a href="/settings">Настройки</a> - там одна команда для установки.</p>
|
||||||
|
{{else}}<p class="muted">Вам пока не выдали ни одной машины - попросите администратора.</p>{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -100,5 +101,6 @@
|
|||||||
if (Object.keys(rows).length) schedule();
|
if (Object.keys(rows).length) schedule();
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
<script src="{{asset "account.js"}}"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -4,11 +4,13 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Изменить машину - LPRA</title>
|
<title>Изменить машину - LPRA</title>
|
||||||
<link rel="stylesheet" href="/static/app.css?v={{.ServerVersion}}-2">
|
<link rel="stylesheet" href="{{asset "app.css"}}">
|
||||||
|
<script src="{{asset "pin.js"}}"></script>
|
||||||
|
<script src="{{asset "scroll.js"}}" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div class="brand">LPRA · Изменить машину</div>
|
<div class="brand"><a class="home" href="/" title="К списку машин">LPRA</a> · Изменить машину</div>
|
||||||
<nav>
|
<nav>
|
||||||
<a href="/">← Машины</a>
|
<a href="/">← Машины</a>
|
||||||
<a href="/agents/{{.Agent.ID}}/terminal">Терминал</a>
|
<a href="/agents/{{.Agent.ID}}/terminal">Терминал</a>
|
||||||
@@ -72,9 +74,29 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Связь с сервером</h2>
|
||||||
|
<p class="muted">
|
||||||
|
Сейчас: {{if not .Online}}<span>офлайн</span>{{else if .OnWS}}<span style="color:var(--ok)">WebSocket</span>{{else}}<span>long polling</span>{{end}}{{if and .Online .ViaDirect}} <span class="muted">(direct)</span>{{end}}
|
||||||
|
</p>
|
||||||
|
<form method="post" action="/agents/{{.Agent.ID}}/transport" class="row">
|
||||||
|
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||||
|
<select name="transport">
|
||||||
|
<option value="auto" {{if eq .Transport "auto"}}selected{{end}}>Авто: WebSocket, а если не выходит - long polling</option>
|
||||||
|
<option value="ws" {{if eq .Transport "ws"}}selected{{end}}>Только WebSocket</option>
|
||||||
|
<option value="lp" {{if eq .Transport "lp"}}selected{{end}}>Только long polling</option>
|
||||||
|
</select>
|
||||||
|
<button type="submit">Сохранить</button>
|
||||||
|
</form>
|
||||||
|
<p class="muted" style="font-size:13px; margin-top:10px">WebSocket держит одно соединение: команды и терминал идут по нему без отдельного запроса на каждое нажатие.
|
||||||
|
В режиме «Авто» клиент, потеряв WebSocket, переходит на long polling и раз в минуту пробует вернуться.
|
||||||
|
Нужен клиент 0.1.0+ и прокси, пропускающий WebSocket (nginx: заголовки Upgrade и Connection).</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Версия клиента</h2>
|
<h2>Версия клиента</h2>
|
||||||
{{if .Updated}}<div class="err" style="background:#1d3a24;border-color:#2b6b3a;color:#b3ffcb">Команда обновления отправлена. Клиент скачает свежий бинарник и перезапустится.</div>{{end}}
|
{{if .Updated}}<div class="err" style="background:#1d3a24;border-color:#2b6b3a;color:#b3ffcb">Клиент {{if .UpdateMsg}}{{.UpdateMsg}}{{else}}обновлён и перезапускается{{end}}. Через несколько секунд он вернётся с новой версией.</div>{{end}}
|
||||||
|
{{if .UpdateErr}}<div class="err">Обновление не вышло: {{.UpdateErr}}</div>{{end}}
|
||||||
<p class="muted">
|
<p class="muted">
|
||||||
Клиент: <code>{{if .Agent.Version}}{{.Agent.Version}}{{else}}неизвестно{{end}}</code>
|
Клиент: <code>{{if .Agent.Version}}{{.Agent.Version}}{{else}}неизвестно{{end}}</code>
|
||||||
· Сервер: <code>{{.ServerVersion}}</code>
|
· Сервер: <code>{{.ServerVersion}}</code>
|
||||||
@@ -91,7 +113,7 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
{{if .IsAdmin}}<div class="card">
|
||||||
<h2>Удаление</h2>
|
<h2>Удаление</h2>
|
||||||
<p class="muted">Удаляет машину из панели. Сервис на самой машине продолжит работать,
|
<p class="muted">Удаляет машину из панели. Сервис на самой машине продолжит работать,
|
||||||
но перестанет быть виден; чтобы подключиться снова - переустановите агента.</p>
|
но перестанет быть виден; чтобы подключиться снова - переустановите агента.</p>
|
||||||
@@ -100,7 +122,7 @@
|
|||||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||||
<button class="btn danger" type="submit">Удалить машину</button>
|
<button class="btn danger" type="submit">Удалить машину</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>{{end}}
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -4,8 +4,10 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Файлы · {{.Agent.Name}} - LPRA</title>
|
<title>Файлы · {{.Agent.Name}} - LPRA</title>
|
||||||
<link rel="stylesheet" href="/static/app.css">
|
<link rel="stylesheet" href="{{asset "app.css"}}">
|
||||||
<link rel="stylesheet" href="/static/codemirror/codemirror.css">
|
<script src="{{asset "pin.js"}}"></script>
|
||||||
|
<script src="{{asset "scroll.js"}}" defer></script>
|
||||||
|
<link rel="stylesheet" href="{{asset "codemirror/codemirror.css"}}">
|
||||||
<style>
|
<style>
|
||||||
.fm-bar { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-bottom:12px; }
|
.fm-bar { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-bottom:12px; }
|
||||||
/* Normalize buttons and the file <label> button so they line up on one row. */
|
/* Normalize buttons and the file <label> button so they line up on one row. */
|
||||||
@@ -13,17 +15,33 @@
|
|||||||
display:inline-flex; align-items:center; justify-content:center;
|
display:inline-flex; align-items:center; justify-content:center;
|
||||||
vertical-align:middle; height:34px; line-height:1; margin:0; box-sizing:border-box;
|
vertical-align:middle; height:34px; line-height:1; margin:0; box-sizing:border-box;
|
||||||
}
|
}
|
||||||
|
/* A button with an icon before its text. */
|
||||||
|
.fm-bar .btn.ico { gap:6px; }
|
||||||
|
.fm-bar .spacer { flex:1; }
|
||||||
input.fm-path { font-family:ui-monospace,Menlo,monospace; font-size:13px; color:var(--muted);
|
input.fm-path { font-family:ui-monospace,Menlo,monospace; font-size:13px; color:var(--muted);
|
||||||
background:#0c0f13; border:1px solid var(--border); border-radius:8px; padding:8px 12px;
|
background:#0c0f13; border:1px solid var(--border); border-radius:8px; padding:8px 12px;
|
||||||
flex:1; min-width:200px; height:34px; box-sizing:border-box; }
|
flex:1; min-width:200px; height:34px; box-sizing:border-box; }
|
||||||
input.fm-path:focus { outline:none; border-color:var(--accent); color:var(--text); }
|
input.fm-path:focus { outline:none; border-color:var(--accent); color:var(--text); }
|
||||||
|
/* The path field, with the folder's properties button inside it on the right. */
|
||||||
|
.fm-pathbox { position:relative; flex:1; min-width:200px; display:flex; }
|
||||||
|
.fm-pathbox input.fm-path { width:100%; min-width:0; padding-right:40px; }
|
||||||
|
.fm-bar .fm-pathbox .btn.sq { position:absolute; right:4px; top:4px; width:26px; height:26px; border:1px solid transparent; }
|
||||||
|
.fm-bar .fm-pathbox .btn.sq:hover { border-color:var(--border); background:#151a21; }
|
||||||
.spinner { width:16px; height:16px; border:2px solid var(--border); border-top-color:var(--accent);
|
.spinner { width:16px; height:16px; border:2px solid var(--border); border-top-color:var(--accent);
|
||||||
border-radius:50%; animation:spin .7s linear infinite; display:none; }
|
border-radius:50%; animation:spin .7s linear infinite; display:inline-block; flex:none; }
|
||||||
@keyframes spin { to { transform:rotate(360deg); } }
|
@keyframes spin { to { transform:rotate(360deg); } }
|
||||||
/* Busy indicator; while files upload it also shows how far they got, and a
|
/* Busy indicator; while files upload it also shows how far they got, and a
|
||||||
click on it lists them. */
|
click on it lists them. It takes room only while it shows. */
|
||||||
.busy { display:inline-flex; align-items:center; gap:6px; background:none; border:0; padding:0 4px;
|
#uppct { display:inline-block; max-width:0; margin-left:0; overflow:hidden; white-space:nowrap;
|
||||||
|
transition:max-width .3s ease, margin-left .3s ease; }
|
||||||
|
#uppct.on { max-width:110px; margin-left:8px; }
|
||||||
|
.busy { flex:none; display:none; align-items:center; gap:8px; background:none; border:0; padding:0 2px;
|
||||||
height:34px; color:var(--muted); font-size:13px; font-variant-numeric:tabular-nums; cursor:default; }
|
height:34px; color:var(--muted); font-size:13px; font-variant-numeric:tabular-nums; cursor:default; }
|
||||||
|
/* Folded to nothing when idle (the negative margin takes back the bar's
|
||||||
|
gap), it opens smoothly, and only for work that lasts a moment. */
|
||||||
|
.fm-bar .busy { display:inline-flex; gap:0; box-sizing:border-box; max-width:0; padding:0; margin-left:-8px; opacity:0;
|
||||||
|
overflow:hidden; white-space:nowrap; transition:max-width .3s ease, margin-left .3s ease, opacity .25s ease; }
|
||||||
|
.fm-bar .busy.on { max-width:140px; margin-left:0; opacity:1; transition-delay:.15s; }
|
||||||
.busy:hover { background:none; }
|
.busy:hover { background:none; }
|
||||||
.busy.up { cursor:pointer; color:var(--text); }
|
.busy.up { cursor:pointer; color:var(--text); }
|
||||||
.busy.up:hover { color:var(--accent); }
|
.busy.up:hover { color:var(--accent); }
|
||||||
@@ -42,7 +60,9 @@
|
|||||||
table.fm td, table.fm th { padding:7px 8px; }
|
table.fm td, table.fm th { padding:7px 8px; }
|
||||||
.fm .nm { cursor:pointer; }
|
.fm .nm { cursor:pointer; }
|
||||||
.fm .nm:hover { color:var(--accent); }
|
.fm .nm:hover { color:var(--accent); }
|
||||||
.ic { display:inline-block; width:18px; text-align:center; margin-right:6px; }
|
.ic { display:inline-flex; vertical-align:-3px; width:18px; justify-content:center; margin-right:6px; color:var(--muted); }
|
||||||
|
.ic.dir { color:#7aa7d9; }
|
||||||
|
.ic.dir svg { fill:rgba(122,167,217,.16); }
|
||||||
.sz { color:var(--muted); font-variant-numeric:tabular-nums; }
|
.sz { color:var(--muted); font-variant-numeric:tabular-nums; }
|
||||||
.act { display:flex; gap:6px; justify-content:flex-end; }
|
.act { display:flex; gap:6px; justify-content:flex-end; }
|
||||||
/* Sortable headers: a click sorts by the column, a second one turns it round. */
|
/* Sortable headers: a click sorts by the column, a second one turns it round. */
|
||||||
@@ -59,11 +79,15 @@
|
|||||||
table.fm .c-act { width:1%; }
|
table.fm .c-act { width:1%; }
|
||||||
table.fm td, table.fm th { padding:7px 4px; }
|
table.fm td, table.fm th { padding:7px 4px; }
|
||||||
.ic { margin-right:4px; }
|
.ic { margin-right:4px; }
|
||||||
.fm-bar input.fm-path { flex:1 1 100%; order:-1; }
|
.fm-bar .fm-pathbox { flex:1 1 100%; order:-1; }
|
||||||
|
/* The selection's buttons go together on a line of their own. */
|
||||||
|
.fm-bar .spacer { flex:1 1 100%; height:0; }
|
||||||
}
|
}
|
||||||
dialog { background:var(--panel); color:var(--text); border:1px solid var(--border);
|
dialog { background:var(--panel); color:var(--text); border:1px solid var(--border);
|
||||||
border-radius:12px; padding:0; max-width:900px; width:92vw; }
|
border-radius:12px; padding:0; max-width:900px; width:92vw; }
|
||||||
dialog::backdrop { background:rgba(0,0,0,.6); }
|
/* A dialog takes the focus itself when it opens, rather than its first
|
||||||
|
button, which would light up as if tabbed to. */
|
||||||
|
dialog:focus { outline:none; }
|
||||||
.dlg-h { padding:14px 18px; border-bottom:1px solid var(--border); display:flex; align-items:center; gap:10px; }
|
.dlg-h { padding:14px 18px; border-bottom:1px solid var(--border); display:flex; align-items:center; gap:10px; }
|
||||||
.dlg-b { padding:16px 18px; }
|
.dlg-b { padding:16px 18px; }
|
||||||
#save { display:inline-flex; align-items:center; gap:8px; }
|
#save { display:inline-flex; align-items:center; gap:8px; }
|
||||||
@@ -71,10 +95,92 @@
|
|||||||
#save.saving::before { content:''; width:12px; height:12px; border:2px solid rgba(255,255,255,.35); border-top-color:#fff;
|
#save.saving::before { content:''; width:12px; height:12px; border:2px solid rgba(255,255,255,.35); border-top-color:#fff;
|
||||||
border-radius:50%; animation:spin .7s linear infinite; }
|
border-radius:50%; animation:spin .7s linear infinite; }
|
||||||
#save[hidden] { display:none; }
|
#save[hidden] { display:none; }
|
||||||
|
/* Square icon buttons, the height of the buttons beside them. */
|
||||||
|
.btn.sq { width:28px; height:28px; padding:0; display:inline-flex; align-items:center; justify-content:center; }
|
||||||
|
.fm-bar .btn.sq { width:34px; }
|
||||||
|
/* Move or copy: the action, where to with suggestions, the name, and what
|
||||||
|
to do with what is already there. */
|
||||||
|
#mvdlg { max-width:560px; }
|
||||||
|
#mvdlg .dlg-b { padding:14px 18px 16px; }
|
||||||
|
.mv label { margin:12px 0 4px; }
|
||||||
|
.mv .pp-path { font-family:ui-monospace,Menlo,monospace; font-size:13px; overflow-wrap:anywhere; }
|
||||||
|
.mv .pp-act { display:flex; justify-content:flex-end; margin-top:16px; }
|
||||||
|
.mv input[type=text], .mv select { width:100%; height:36px; padding:6px 10px; }
|
||||||
|
.mv select { padding-right:36px; }
|
||||||
|
.seg { display:inline-flex; margin-top:12px; border:1px solid var(--border); border-radius:8px; overflow:hidden; }
|
||||||
|
.seg button { background:transparent; color:var(--muted); border:0; border-radius:0; padding:7px 16px; font-size:14px; }
|
||||||
|
.seg button + button { border-left:1px solid var(--border); }
|
||||||
|
.seg button.on { background:#12202f; color:var(--text); box-shadow:inset 0 -2px 0 var(--accent); }
|
||||||
|
.seg button:hover { background:#151a21; color:var(--text); }
|
||||||
|
.mv-top { display:flex; align-items:flex-end; gap:8px; }
|
||||||
|
.mv-top .mv-net { height:36px; width:36px; flex:none; color:var(--muted); transition:background .18s, color .18s, border-color .18s; }
|
||||||
|
.mv-top .mv-net:hover { color:var(--text); }
|
||||||
|
.mv-top .mv-net.on { color:var(--accent); border-color:var(--accent); background:#12202f; }
|
||||||
|
/* The other machine: slides open under the action. */
|
||||||
|
.mv-remote { display:grid; grid-template-rows:0fr; opacity:0; transition:grid-template-rows .25s ease, opacity .2s ease; }
|
||||||
|
.mv-remote > div { overflow:hidden; min-height:0; }
|
||||||
|
.mv-remote.on { grid-template-rows:1fr; opacity:1; }
|
||||||
|
.mv-remote.on > div { overflow:visible; }
|
||||||
|
.mv-ssh { display:grid; gap:8px; margin-top:10px; padding:10px 12px; border:1px solid var(--border); border-radius:9px; background:#0f1318;
|
||||||
|
animation:mv-in .2s ease; }
|
||||||
|
.mv-ssh[hidden], .mv-a[hidden] { display:none; }
|
||||||
|
@keyframes mv-in { from { opacity:0; transform:translateY(-4px); } }
|
||||||
|
.mv-ssh label { margin:0; display:grid; gap:4px; font-size:12px; color:var(--muted); }
|
||||||
|
.mv-ssh input[type=number] { width:100%; height:36px; padding:6px 10px; }
|
||||||
|
.mv-row { display:flex; gap:10px; align-items:end; }
|
||||||
|
.mv-row .grow { flex:1; min-width:0; }
|
||||||
|
.mv-row .port { width:92px; flex:none; }
|
||||||
|
.mv-ssh .seg-auth { margin-top:2px; display:flex; }
|
||||||
|
.mv-ssh .seg-auth button { flex:1; padding:6px 8px; font-size:13px; }
|
||||||
|
@media (max-width:520px) { .mv-ssh .seg-auth button { font-size:12px; padding:6px 4px; white-space:nowrap; } }
|
||||||
|
.mv-a { display:grid; gap:8px; animation:mv-in .18s ease; }
|
||||||
|
.mv-a textarea { width:100%; box-sizing:border-box; font:12px/1.4 ui-monospace,Menlo,monospace; resize:vertical; min-height:80px;
|
||||||
|
background:var(--bg); color:var(--text); border:1px solid var(--border); border-radius:8px; padding:8px 10px; }
|
||||||
|
.mv-a textarea:focus { border-color:var(--accent); outline:none; }
|
||||||
|
p.mv-a { margin:0; font-size:13px; }
|
||||||
|
#mv-keyname { font-size:12px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; align-self:center; }
|
||||||
|
.mv-prog { margin-top:14px; animation:mv-in .2s ease; }
|
||||||
|
.mv-prog[hidden] { display:none; }
|
||||||
|
.mv-bar { height:6px; border-radius:3px; background:#1a2029; overflow:hidden; }
|
||||||
|
.mv-bar i { display:block; height:100%; width:0; background:var(--accent); border-radius:3px; transition:width .5s ease; }
|
||||||
|
.mv-bar.wait i { width:30% !important; animation:mv-slide 1.1s ease-in-out infinite; }
|
||||||
|
@keyframes mv-slide { from { transform:translateX(-100%); } to { transform:translateX(340%); } }
|
||||||
|
.mv-pwrap { display:inline-flex; align-items:center; gap:8px; min-width:0; }
|
||||||
|
.mv-ptext { display:flex; align-items:center; justify-content:space-between; gap:10px; margin-top:8px; font-size:13px; color:var(--muted); }
|
||||||
|
.mv.running input, .mv.running select, .mv.running textarea, .mv.running .seg, .mv.running .mv-net { pointer-events:none; opacity:.6; }
|
||||||
|
.mv-dirbox { position:relative; }
|
||||||
|
.mv-dirbox input { font-family:ui-monospace,Menlo,monospace; font-size:13px; }
|
||||||
|
.mv-sug { position:absolute; left:0; right:0; top:calc(100% + 4px); z-index:5; max-height:220px; overflow:auto;
|
||||||
|
margin:0; padding:4px; list-style:none; background:var(--panel); border:1px solid var(--border); border-radius:8px;
|
||||||
|
box-shadow:0 8px 24px rgba(0,0,0,.45); font-family:ui-monospace,Menlo,monospace; font-size:13px; }
|
||||||
|
.mv-sug li { padding:5px 8px; border-radius:5px; cursor:pointer; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||||
|
.mv-sug li.sel, .mv-sug li:hover { background:#12202f; color:var(--text); }
|
||||||
|
.mv-sug li .muted { color:var(--muted); }
|
||||||
|
/* Properties: owner, group and mode, like FileZilla's. */
|
||||||
|
#propdlg { max-width:520px; }
|
||||||
|
#propdlg .dlg-b { padding:14px 18px 16px; }
|
||||||
|
.props .pp-path { font-family:ui-monospace,Menlo,monospace; font-size:13px; overflow-wrap:anywhere; }
|
||||||
|
.props .pp-info { font-size:13px; margin:2px 0 12px; }
|
||||||
|
.props .pp-own { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
|
||||||
|
.props .pp-own label { margin:0; }
|
||||||
|
.props .pp-own input { margin-top:4px; height:36px; padding:6px 10px; }
|
||||||
|
.props table.pp-perm { margin:12px 0 4px; }
|
||||||
|
.props .pp-perm th, .props .pp-perm td { padding:4px 8px; text-align:center; font-size:13px; }
|
||||||
|
.props .pp-perm tbody th { text-align:left; color:var(--text); font-weight:400; }
|
||||||
|
.props .pp-perm input { width:16px; height:16px; cursor:pointer; }
|
||||||
|
.props .pp-num { display:flex; align-items:center; gap:10px; margin:8px 0 0; }
|
||||||
|
.props .pp-num input { width:80px; height:34px; padding:4px 10px; font-family:ui-monospace,Menlo,monospace; }
|
||||||
|
.props .pp-num input.bad { border-color:var(--danger); }
|
||||||
|
.props .pp-rec { display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between; gap:8px 16px; margin-top:12px; padding-top:12px; border-top:1px solid var(--border); }
|
||||||
|
.props .pp-rec label.check { display:inline-flex; align-items:center; gap:8px; margin:0; color:var(--text); font-size:14px; cursor:pointer; }
|
||||||
|
.props .pp-rec select { min-width:0; padding:6px 32px 6px 12px; }
|
||||||
|
.props .pp-act { display:flex; justify-content:flex-end; margin-top:14px; }
|
||||||
/* File viewer: the code editor, a picture / video / sound, or the hex editor. */
|
/* File viewer: the code editor, a picture / video / sound, or the hex editor. */
|
||||||
#viewer { max-width:1100px; }
|
#viewer { max-width:1100px; }
|
||||||
#viewer .dlg-h { flex-wrap:wrap; }
|
#viewer .dlg-h { flex-wrap:wrap; }
|
||||||
.vpath { flex:1; min-width:0; overflow:hidden; white-space:nowrap; text-overflow:ellipsis;
|
/* Right to left so a long path is cut at its start, keeping the name in
|
||||||
|
sight; still set to the left like any title. */
|
||||||
|
.vpath { flex:1; min-width:0; overflow:hidden; white-space:nowrap; text-overflow:ellipsis; text-align:left;
|
||||||
font-family:ui-monospace,Menlo,monospace; font-size:13px; font-weight:600; }
|
font-family:ui-monospace,Menlo,monospace; font-size:13px; font-weight:600; }
|
||||||
.vmsg { width:100%; order:9; font-size:12px; }
|
.vmsg { width:100%; order:9; font-size:12px; }
|
||||||
.vbody { padding:0; }
|
.vbody { padding:0; }
|
||||||
@@ -101,6 +207,53 @@
|
|||||||
.find #frow2, .find #frow2b { grid-row:2; }
|
.find #frow2, .find #frow2b { grid-row:2; }
|
||||||
/* Documents and tables, drawn in the panel's colours. */
|
/* Documents and tables, drawn in the panel's colours. */
|
||||||
.vdoc { height:75vh; overflow:auto; background:#0c0f13; border-radius:0 0 12px 12px; }
|
.vdoc { height:75vh; overflow:auto; background:#0c0f13; border-radius:0 0 12px 12px; }
|
||||||
|
/* SQLite editor. */
|
||||||
|
.dbx { display:flex; flex-direction:column; height:100%; transition:opacity .2s; }
|
||||||
|
.dbx.busy { opacity:.75; }
|
||||||
|
.dbx-tabs { display:flex; gap:4px; padding:8px 10px 0; overflow-x:auto; flex:none; border-bottom:1px solid var(--border); }
|
||||||
|
.dbx-tabs button { flex:none; background:transparent; color:var(--muted); border:1px solid transparent; border-bottom:0; border-radius:8px 8px 0 0;
|
||||||
|
padding:6px 12px; font-size:13px; transition:background .15s, color .15s; }
|
||||||
|
.dbx-tabs button:hover { color:var(--text); background:#151a21; }
|
||||||
|
.dbx-tabs button.on { color:var(--text); background:var(--panel); border-color:var(--border); }
|
||||||
|
.dbx-tabs .dbx-sqltab { margin-left:auto; font-family:ui-monospace,Menlo,monospace; }
|
||||||
|
.dbx-tools { display:flex; align-items:center; gap:8px; padding:8px 10px; flex:none; flex-wrap:wrap; font-size:13px; }
|
||||||
|
.dbx-tools[hidden], .dbx-pager[hidden], .dbx-grid[hidden], .dbx-sql[hidden], .dbx-tools button[hidden] { display:none; }
|
||||||
|
.dbx-pager { display:inline-flex; align-items:center; gap:6px; }
|
||||||
|
.dbx-sp { flex:1; }
|
||||||
|
.dbx-grid { flex:1; min-height:0; overflow:auto; }
|
||||||
|
.dbx-t { width:auto; border-collapse:separate; border-spacing:0; font-size:13px; font-variant-numeric:tabular-nums; }
|
||||||
|
.dbx-t th, .dbx-t td { border-right:1px solid var(--border); border-bottom:1px solid var(--border); padding:5px 10px; text-align:left;
|
||||||
|
max-width:420px; white-space:pre-wrap; overflow-wrap:anywhere; vertical-align:top; }
|
||||||
|
.dbx-t thead th { position:sticky; top:0; z-index:1; background:var(--panel); color:var(--text); font-weight:500; white-space:nowrap; }
|
||||||
|
.dbx-t thead th small { display:block; color:var(--muted); font-weight:400; font-size:11px; }
|
||||||
|
.dbx-t td.num { text-align:right; }
|
||||||
|
.dbx-t .dbx-k { color:var(--muted); text-align:right; }
|
||||||
|
.dbx-t .dbx-ck { width:1%; text-align:center; }
|
||||||
|
.dbx-t td.ed { cursor:text; transition:background .15s; }
|
||||||
|
.dbx-t td.ed:hover { background:#151a21; }
|
||||||
|
.dbx-t td.editing { padding:0; background:#12202f; }
|
||||||
|
.dbx-t td.saving { opacity:.6; }
|
||||||
|
.dbx-t td.flash-ok { animation:dbx-ok 1.2s ease; }
|
||||||
|
.dbx-t td.flash-bad { animation:dbx-bad 1.2s ease; }
|
||||||
|
@keyframes dbx-ok { 0% { background:rgba(55,200,113,.28); } 100% { background:transparent; } }
|
||||||
|
@keyframes dbx-bad { 0% { background:rgba(255,92,92,.3); } 100% { background:transparent; } }
|
||||||
|
.dbx-null { color:var(--muted); font-size:12px; }
|
||||||
|
.dbx-edit { position:relative; }
|
||||||
|
.dbx-edit textarea { display:block; width:100%; min-height:30px; resize:vertical; box-sizing:border-box; margin:0; padding:5px 28px 5px 10px; border:1px solid var(--accent);
|
||||||
|
border-radius:0; background:#0c0f13; color:var(--text); font:inherit; outline:none; }
|
||||||
|
.dbx-nullb { position:absolute; top:4px; right:4px; width:20px; height:20px; padding:0; font-size:12px; line-height:1;
|
||||||
|
background:#1b222c; color:var(--muted); border:1px solid var(--border); border-radius:5px; }
|
||||||
|
.dbx-nullb:hover { color:var(--text); background:#232b36; }
|
||||||
|
.dbx-t tr.dbx-new td { padding:3px; background:#12202f; }
|
||||||
|
.dbx-t tr.dbx-new input { width:100%; min-width:90px; height:28px; padding:3px 8px; font-size:13px; }
|
||||||
|
.dbx-t tr.dbx-newbar td { background:#12202f; padding:6px 8px; white-space:nowrap; }
|
||||||
|
.dbx-sql { flex:1; min-height:0; display:flex; flex-direction:column; gap:8px; padding:10px; }
|
||||||
|
.dbx-q { flex:none; height:110px; resize:vertical; font:13px/1.5 ui-monospace,Menlo,monospace; background:#0c0f13; color:var(--text);
|
||||||
|
border:1px solid var(--border); border-radius:8px; padding:8px 10px; }
|
||||||
|
.dbx-q:focus { border-color:var(--accent); outline:none; }
|
||||||
|
.dbx-sqlbar { display:flex; align-items:center; gap:10px; font-size:13px; }
|
||||||
|
.dbx-sqlmsg.bad { color:var(--danger); }
|
||||||
|
.dbx-sqlres { flex:1; min-height:0; overflow:auto; }
|
||||||
.vdoc .dv-pdf { display:block; width:100%; height:100%; border:0; background:#525659; }
|
.vdoc .dv-pdf { display:block; width:100%; height:100%; border:0; background:#525659; }
|
||||||
.vdoc .dv-html { display:block; width:100%; height:100%; border:0; background:#fff; }
|
.vdoc .dv-html { display:block; width:100%; height:100%; border:0; background:#fff; }
|
||||||
.dv-doc { max-width:820px; margin:0 auto; padding:28px 32px 40px; font-size:15px; line-height:1.65; color:var(--text); overflow-wrap:anywhere; }
|
.dv-doc { max-width:820px; margin:0 auto; padding:28px 32px 40px; font-size:15px; line-height:1.65; color:var(--text); overflow-wrap:anywhere; }
|
||||||
@@ -181,7 +334,7 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div class="brand">LPRA · Файлы - {{.Agent.Name}} <span class="muted">({{.Agent.Hostname}})</span></div>
|
<div class="brand"><a class="home" href="/" title="К списку машин">LPRA</a> · Файлы - {{.Agent.Name}} <span class="muted">({{.Agent.Hostname}})</span></div>
|
||||||
<nav>
|
<nav>
|
||||||
<a href="/">← Машины</a>
|
<a href="/">← Машины</a>
|
||||||
<a href="/agents/{{.Agent.ID}}/terminal">Терминал</a>
|
<a href="/agents/{{.Agent.ID}}/terminal">Терминал</a>
|
||||||
@@ -192,16 +345,19 @@
|
|||||||
</header>
|
</header>
|
||||||
<div class="wrap" style="max-width:1100px">
|
<div class="wrap" style="max-width:1100px">
|
||||||
<div class="fm-bar">
|
<div class="fm-bar">
|
||||||
<button class="btn ghost small" id="up">↑ Вверх</button>
|
<button class="btn ghost small ico" id="up"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5M5 12l7-7 7 7"/></svg>Вверх</button>
|
||||||
<input class="fm-path" id="path" value="/" spellcheck="false" autocapitalize="off" autocomplete="off">
|
<div class="fm-pathbox">
|
||||||
|
<input class="fm-path" id="path" value="/" spellcheck="false" autocapitalize="off" autocomplete="off">
|
||||||
|
<button class="btn ghost small icon sq" id="dirprops" title="Свойства папки" aria-label="Свойства папки"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 4h-7M10 4H3M21 12h-9M8 12H3M21 20h-5M12 20H3M14 2v4M8 10v4M16 18v4"/></svg></button>
|
||||||
|
</div>
|
||||||
<button type="button" class="busy" id="busy" title=""><span class="spinner" id="spin"></span><span id="uppct"></span></button>
|
<button type="button" class="busy" id="busy" title=""><span class="spinner" id="spin"></span><span id="uppct"></span></button>
|
||||||
<button class="btn ghost small" id="refresh">Обновить</button>
|
<button class="btn ghost small" id="refresh">Обновить</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="fm-bar">
|
<div class="fm-bar">
|
||||||
<button class="btn small" id="mkdir">+ Папка</button>
|
<button class="btn small ico" id="mkdir"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/><path d="M12 11v6M9 14h6"/></svg>Папка</button>
|
||||||
<button class="btn small" id="newfile">+ Файл</button>
|
<button class="btn small ico" id="newfile"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6M12 12v6M9 15h6"/></svg>Файл</button>
|
||||||
<label class="btn small" style="cursor:pointer">+ Загрузить<input type="file" id="upl" multiple hidden></label>
|
<label class="btn small ico" style="cursor:pointer"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12M6 10l6 6 6-6M5 21h14"/></svg>Загрузить<input type="file" id="upl" multiple hidden></label>
|
||||||
<span class="spacer" style="flex:1"></span>
|
<span class="spacer"></span>
|
||||||
<span id="selinfo" class="muted" style="font-size:13px"></span>
|
<span id="selinfo" class="muted" style="font-size:13px"></span>
|
||||||
<button class="btn ghost small" id="zipsel" disabled>В zip</button>
|
<button class="btn ghost small" id="zipsel" disabled>В zip</button>
|
||||||
<button class="btn danger small" id="delsel" disabled>Удалить выбранное</button>
|
<button class="btn danger small" id="delsel" disabled>Удалить выбранное</button>
|
||||||
@@ -220,12 +376,13 @@
|
|||||||
<p class="muted" id="hint" style="font-size:13px">Перетащите файлы сюда для загрузки. Клик по файлу - просмотр и правка.</p>
|
<p class="muted" id="hint" style="font-size:13px">Перетащите файлы сюда для загрузки. Клик по файлу - просмотр и правка.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<dialog id="viewer">
|
<dialog id="viewer" tabindex="-1">
|
||||||
<div class="dlg-h">
|
<div class="dlg-h">
|
||||||
<strong class="vpath" dir="rtl"><bdi id="vname"></bdi></strong>
|
<strong class="vpath" dir="rtl"><bdi id="vname"></bdi></strong>
|
||||||
<button class="btn ghost small" id="vtext" hidden>Редактировать</button>
|
<button class="btn ghost small" id="vtext" hidden>Редактировать</button>
|
||||||
<button class="btn ghost small" id="vhex" hidden>Редактировать бинарно</button>
|
<button class="btn ghost small" id="vhex" hidden>Редактировать бинарно</button>
|
||||||
<button class="btn small" id="save" hidden>Сохранить</button>
|
<button class="btn small" id="save" hidden>Сохранить</button>
|
||||||
|
<button class="btn ghost small icon sq" id="vprops" title="Свойства" aria-label="Свойства"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 4h-7M10 4H3M21 12h-9M8 12H3M21 20h-5M12 20H3M14 2v4M8 10v4M16 18v4"/></svg></button>
|
||||||
<button class="btn ghost small icon close x" id="closev" title="Закрыть" aria-label="Закрыть"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
|
<button class="btn ghost small icon close x" id="closev" title="Закрыть" aria-label="Закрыть"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
|
||||||
<div class="vmsg muted" id="vmsg"></div>
|
<div class="vmsg muted" id="vmsg"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -258,7 +415,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
<dialog id="updlg">
|
<dialog id="updlg" tabindex="-1">
|
||||||
<div class="dlg-h">
|
<div class="dlg-h">
|
||||||
<strong style="flex:1">Загрузка файлов</strong>
|
<strong style="flex:1">Загрузка файлов</strong>
|
||||||
<button class="btn ghost small icon close x" id="updlg-close" title="Закрыть" aria-label="Закрыть"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
|
<button class="btn ghost small icon close x" id="updlg-close" title="Закрыть" aria-label="Закрыть"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
|
||||||
@@ -266,15 +423,104 @@
|
|||||||
<div class="dlg-b" id="updlg-list"></div>
|
<div class="dlg-b" id="updlg-list"></div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
|
<dialog id="mvdlg" tabindex="-1">
|
||||||
|
<div class="dlg-h">
|
||||||
|
<strong style="flex:1">Переместить или копировать</strong>
|
||||||
|
<button class="btn ghost small icon close x" id="mvdlg-close" title="Закрыть" aria-label="Закрыть"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
|
||||||
|
</div>
|
||||||
|
<div class="dlg-b mv">
|
||||||
|
<div class="pp-path" id="mv-src"></div>
|
||||||
|
<div class="mv-top">
|
||||||
|
<div class="seg" id="mv-op" role="radiogroup">
|
||||||
|
<button type="button" data-op="move" role="radio">Переместить</button>
|
||||||
|
<button type="button" data-op="copy" role="radio">Копировать</button>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn ghost sq mv-net" id="mv-net" title="На другую машину или по SSH" aria-label="На другую машину или по SSH" aria-pressed="false"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2" width="6" height="6" rx="1"/><rect x="2" y="16" width="6" height="6" rx="1"/><rect x="16" y="16" width="6" height="6" rx="1"/><path d="M12 8v4M5 16v-2a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v2"/></svg></button>
|
||||||
|
</div>
|
||||||
|
<div class="mv-remote" id="mv-remote"><div>
|
||||||
|
<label for="mv-to">На машину</label>
|
||||||
|
<select id="mv-to"></select>
|
||||||
|
<div class="mv-ssh" id="mv-ssh" hidden>
|
||||||
|
<div class="mv-row"><label class="grow">Хост<input type="text" id="mv-host" placeholder="example.com" autocomplete="off" spellcheck="false" autocapitalize="off"></label>
|
||||||
|
<label class="port">Порт<input type="number" id="mv-port" min="1" max="65535" value="22"></label></div>
|
||||||
|
<label>Пользователь<input type="text" id="mv-user" placeholder="root" autocomplete="off" spellcheck="false" autocapitalize="off"></label>
|
||||||
|
<div class="seg seg-auth" id="mv-auth">
|
||||||
|
<button type="button" data-auth="password" class="on">Пароль</button><button type="button" data-auth="key">Ключ</button><button type="button" data-auth="agent">Ключи агента</button>
|
||||||
|
</div>
|
||||||
|
<div class="mv-a" data-for="password"><input type="password" id="mv-pw" placeholder="пароль" autocomplete="off"></div>
|
||||||
|
<div class="mv-a" data-for="key" hidden>
|
||||||
|
<textarea id="mv-key" rows="4" spellcheck="false" placeholder="-----BEGIN OPENSSH PRIVATE KEY----- …вставьте ключ или загрузите файл"></textarea>
|
||||||
|
<div class="mv-row"><button type="button" class="btn ghost small" id="mv-keyload">Загрузить файл…</button><span class="muted" id="mv-keyname"></span><input type="file" id="mv-keyfile" hidden></div>
|
||||||
|
<input type="password" id="mv-pass" placeholder="фраза ключа, если есть" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<p class="mv-a muted" data-for="agent" hidden>Агент войдёт своими ключами из ~/.ssh без фразы.</p>
|
||||||
|
</div>
|
||||||
|
</div></div>
|
||||||
|
<label for="mv-dir">Куда (папка)</label>
|
||||||
|
<div class="mv-dirbox">
|
||||||
|
<input type="text" id="mv-dir" autocomplete="off" spellcheck="false" autocapitalize="off">
|
||||||
|
<ul class="mv-sug" id="mv-sug" hidden></ul>
|
||||||
|
</div>
|
||||||
|
<label for="mv-name">Имя</label>
|
||||||
|
<input type="text" id="mv-name" autocomplete="off" spellcheck="false" autocapitalize="off">
|
||||||
|
<label for="mv-conflict">Если там уже есть такое</label>
|
||||||
|
<select id="mv-conflict">
|
||||||
|
<option value="error">ничего не трогать и сообщить</option>
|
||||||
|
<option value="replace">заменить (папки слить, файлы заменить)</option>
|
||||||
|
<option value="skip">оставить существующее (папки слить)</option>
|
||||||
|
</select>
|
||||||
|
<div class="mv-prog" id="mv-prog" hidden>
|
||||||
|
<div class="mv-bar"><i id="mv-bar"></i></div>
|
||||||
|
<div class="mv-ptext"><span class="mv-pwrap"><span class="spinner"></span><span id="mv-ptext">подготовка…</span></span><button type="button" class="btn ghost small" id="mv-stop">Отменить</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="pp-act"><button type="button" id="mv-go">Переместить</button></div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<dialog id="propdlg" tabindex="-1">
|
||||||
|
<div class="dlg-h">
|
||||||
|
<strong style="flex:1">Свойства</strong>
|
||||||
|
<button class="btn ghost small icon close x" id="propdlg-close" title="Закрыть" aria-label="Закрыть"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
|
||||||
|
</div>
|
||||||
|
<div class="dlg-b props">
|
||||||
|
<div class="pp-path" id="pp-path"></div>
|
||||||
|
<div class="pp-info muted" id="pp-info"></div>
|
||||||
|
<div class="pp-own">
|
||||||
|
<label>Владелец<input type="text" id="pp-owner" list="pp-users" autocomplete="off" spellcheck="false"></label>
|
||||||
|
<label>Группа<input type="text" id="pp-group" list="pp-groups" autocomplete="off" spellcheck="false"></label>
|
||||||
|
</div>
|
||||||
|
<datalist id="pp-users"></datalist><datalist id="pp-groups"></datalist>
|
||||||
|
<table class="pp-perm">
|
||||||
|
<thead><tr><th></th><th>Чтение</th><th>Запись</th><th>Выполнение</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><th>Владелец</th><td><input type="checkbox" data-bit="256"></td><td><input type="checkbox" data-bit="128"></td><td><input type="checkbox" data-bit="64"></td></tr>
|
||||||
|
<tr><th>Группа</th><td><input type="checkbox" data-bit="32"></td><td><input type="checkbox" data-bit="16"></td><td><input type="checkbox" data-bit="8"></td></tr>
|
||||||
|
<tr><th>Остальные</th><td><input type="checkbox" data-bit="4"></td><td><input type="checkbox" data-bit="2"></td><td><input type="checkbox" data-bit="1"></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<label class="pp-num">Числовое значение <input type="text" id="pp-mode" maxlength="4" inputmode="numeric" autocomplete="off"></label>
|
||||||
|
<div class="pp-rec" id="pp-rec" hidden>
|
||||||
|
<label class="check"><input type="checkbox" id="pp-recursive"> Применить ко всем вложенным</label>
|
||||||
|
<select id="pp-only" disabled>
|
||||||
|
<option value="">к файлам и папкам</option>
|
||||||
|
<option value="files">только к файлам</option>
|
||||||
|
<option value="dirs">только к папкам</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="pp-act"><button type="button" id="pp-apply">Применить</button></div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
|
|
||||||
<script src="/static/codemirror/codemirror.js"></script>
|
<script src="{{asset "codemirror/codemirror.js"}}"></script>
|
||||||
<script src="/static/docview.js"></script>
|
<script src="{{asset "docview.js"}}"></script>
|
||||||
|
<script src="{{asset "dbedit.js"}}"></script>
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
const agentID = {{.Agent.ID}};
|
const agentID = {{.Agent.ID}};
|
||||||
let busyN = 0;
|
let busyN = 0;
|
||||||
function setBusy(on) { busyN += on ? 1 : -1; if (busyN < 0) busyN = 0; document.getElementById('spin').style.display = busyN > 0 ? 'inline-block' : 'none'; }
|
function setBusy(on) { busyN += on ? 1 : -1; if (busyN < 0) busyN = 0; document.getElementById('busy').classList.toggle('on', busyN > 0); }
|
||||||
const api = (op, body) => {
|
const api = (op, body) => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
return fetch('/api/panel/agents/' + encodeURIComponent(agentID) + '/fs/' + op, {
|
return fetch('/api/panel/agents/' + encodeURIComponent(agentID) + '/fs/' + op, {
|
||||||
@@ -290,6 +536,30 @@
|
|||||||
const ICON_MV = SVG + '<polyline points="5 9 2 12 5 15"/><polyline points="9 5 12 2 15 5"/><polyline points="15 19 12 22 9 19"/><polyline points="19 9 22 12 19 15"/><line x1="2" y1="12" x2="22" y2="12"/><line x1="12" y1="2" x2="12" y2="22"/></svg>';
|
const ICON_MV = SVG + '<polyline points="5 9 2 12 5 15"/><polyline points="9 5 12 2 15 5"/><polyline points="15 19 12 22 9 19"/><polyline points="19 9 22 12 19 15"/><line x1="2" y1="12" x2="22" y2="12"/><line x1="12" y1="2" x2="12" y2="22"/></svg>';
|
||||||
const ICON_UNZIP = SVG + '<rect x="2" y="3" width="20" height="5" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h2"/><path d="M20 8v11a2 2 0 0 1-2 2h-2"/><path d="m9 15 3-3 3 3"/><path d="M12 12v9"/></svg>';
|
const ICON_UNZIP = SVG + '<rect x="2" y="3" width="20" height="5" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h2"/><path d="M20 8v11a2 2 0 0 1-2 2h-2"/><path d="m9 15 3-3 3 3"/><path d="M12 12v9"/></svg>';
|
||||||
const ICON_DEL = SVG + '<path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>';
|
const ICON_DEL = SVG + '<path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>';
|
||||||
|
// File icons: one colour, the line style of the buttons; a few kinds by
|
||||||
|
// the name's ending.
|
||||||
|
const FI = '<svg class="fi" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">';
|
||||||
|
const FICONS = {
|
||||||
|
dir: FI + '<path d="M3 7a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2V17a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>',
|
||||||
|
file: FI + '<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/></svg>',
|
||||||
|
text: FI + '<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5M9 13h6M9 17h4"/></svg>',
|
||||||
|
code: FI + '<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5M10 12l-2 2.5 2 2.5M14 12l2 2.5-2 2.5"/></svg>',
|
||||||
|
img: FI + '<rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="9" cy="10" r="1.6"/><path d="m21 16-5-5-8 9"/></svg>',
|
||||||
|
arc: FI + '<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5M11 4v2M11 8v2M11 12v2"/><rect x="9.5" y="15" width="3" height="3" rx=".6"/></svg>',
|
||||||
|
media:FI + '<rect x="3" y="5" width="18" height="14" rx="2"/><path d="m10 9 5 3-5 3z"/></svg>',
|
||||||
|
link: FI + '<path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1"/><path d="M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1"/></svg>'
|
||||||
|
};
|
||||||
|
function fileIcon(e) {
|
||||||
|
let k = 'file';
|
||||||
|
if (e.is_dir) k = 'dir';
|
||||||
|
else if (/^L/.test(e.mode || '')) k = 'link';
|
||||||
|
else if (/\.(png|jpe?g|gif|webp|bmp|ico|svg|avif|heic)$/i.test(e.name)) k = 'img';
|
||||||
|
else if (/\.(zip|tar|gz|tgz|bz2|xz|zst|7z|rar|deb|rpm)$/i.test(e.name)) k = 'arc';
|
||||||
|
else if (/\.(mp4|m4v|webm|mov|mkv|avi|mp3|wav|ogg|flac|m4a|aac|opus)$/i.test(e.name)) k = 'media';
|
||||||
|
else if (/\.(js|ts|go|py|rb|rs|c|h|cpp|java|kt|php|sh|bash|zsh|json|ya?ml|toml|html?|css|sql|lua|pl|swift|vue|jsx|tsx|ini|conf|xml)$/i.test(e.name)) k = 'code';
|
||||||
|
else if (/\.(txt|md|log|csv|rst|cfg|env)$|^(readme|license|makefile|dockerfile)/i.test(e.name)) k = 'text';
|
||||||
|
return '<span class="ic' + (k === 'dir' ? ' dir' : '') + '">' + FICONS[k] + '</span>';
|
||||||
|
}
|
||||||
let cwd = '/';
|
let cwd = '/';
|
||||||
let entries = [];
|
let entries = [];
|
||||||
const selected = new Set();
|
const selected = new Set();
|
||||||
@@ -298,8 +568,10 @@
|
|||||||
// An open dialog sits in the page's top layer, above any z-index: the toast
|
// An open dialog sits in the page's top layer, above any z-index: the toast
|
||||||
// goes inside it then, so "сохранено" or an error shows over the editor.
|
// goes inside it then, so "сохранено" or an error shows over the editor.
|
||||||
let toastTimer = null;
|
let toastTimer = null;
|
||||||
|
// Opens a dialog with the focus on the dialog itself, not its first button.
|
||||||
|
const openModal = d => { d.showModal(); d.focus(); };
|
||||||
const toast = (msg, err) => {
|
const toast = (msg, err) => {
|
||||||
const t = $('toast'), host = document.querySelector('dialog[open]') || document.body;
|
const t = $('toast'), host = [...document.querySelectorAll('dialog[open]')].pop() || document.body;
|
||||||
if (t.parentNode !== host) host.appendChild(t);
|
if (t.parentNode !== host) host.appendChild(t);
|
||||||
t.textContent = msg;
|
t.textContent = msg;
|
||||||
t.className = 'toast show' + (err ? ' err' : '');
|
t.className = 'toast show' + (err ? ' err' : '');
|
||||||
@@ -370,13 +642,13 @@
|
|||||||
const dlHref = '/api/panel/agents/' + encodeURIComponent(agentID) + '/fs/download?path=' + encodeURIComponent(join(cwd, e.name));
|
const dlHref = '/api/panel/agents/' + encodeURIComponent(agentID) + '/fs/download?path=' + encodeURIComponent(join(cwd, e.name));
|
||||||
tr.innerHTML =
|
tr.innerHTML =
|
||||||
'<td><input type="checkbox" class="ck" data-n="'+encodeURIComponent(e.name)+'"></td>' +
|
'<td><input type="checkbox" class="ck" data-n="'+encodeURIComponent(e.name)+'"></td>' +
|
||||||
'<td class="nm"><span class="ic">'+(e.is_dir?'📁':'📄')+'</span>'+esc(e.name)+'</td>' +
|
'<td class="nm">'+fileIcon(e)+esc(e.name)+'</td>' +
|
||||||
'<td class="sz c-sz">'+(e.is_dir?'':fmtSize(e.size))+'</td>' +
|
'<td class="sz c-sz">'+(e.is_dir?'':fmtSize(e.size))+'</td>' +
|
||||||
'<td class="sz mt">'+fmtTime(e.mtime)+'</td>' +
|
'<td class="sz mt">'+fmtTime(e.mtime)+'</td>' +
|
||||||
'<td><div class="act">' +
|
'<td><div class="act">' +
|
||||||
(isZip?'<button class="btn ghost small icon" data-act="unzip" title="Распаковать сюда">'+ICON_UNZIP+'</button>':'') +
|
(isZip?'<button class="btn ghost small icon" data-act="unzip" title="Распаковать сюда">'+ICON_UNZIP+'</button>':'') +
|
||||||
(e.is_dir?'':'<a class="btn ghost small icon" title="Скачать" href="'+dlHref+'" download>'+ICON_DL+'</a>') +
|
(e.is_dir?'':'<a class="btn ghost small icon" title="Скачать" href="'+dlHref+'" download>'+ICON_DL+'</a>') +
|
||||||
'<button class="btn ghost small icon" data-act="ren" title="Переместить / переименовать">'+ICON_MV+'</button>' +
|
'<button class="btn ghost small icon" data-act="ren" title="Переместить или копировать">'+ICON_MV+'</button>' +
|
||||||
'<button class="btn danger small icon" data-act="del" title="Удалить">'+ICON_DEL+'</button>' +
|
'<button class="btn danger small icon" data-act="del" title="Удалить">'+ICON_DEL+'</button>' +
|
||||||
'</div></td>';
|
'</div></td>';
|
||||||
const nm = tr.querySelector('.nm');
|
const nm = tr.querySelector('.nm');
|
||||||
@@ -536,7 +808,7 @@
|
|||||||
V.path = path; V.dirty = false;
|
V.path = path; V.dirty = false;
|
||||||
$('vname').textContent = path;
|
$('vname').textContent = path;
|
||||||
$('vname').parentElement.title = path;
|
$('vname').parentElement.title = path;
|
||||||
if (!$('viewer').open) $('viewer').showModal();
|
if (!$('viewer').open) openModal($('viewer'));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function view(e) {
|
async function view(e) {
|
||||||
@@ -641,6 +913,19 @@
|
|||||||
if (r.data.truncated) $('vmsg').textContent += ' · показаны первые ' + fmtSize(EDIT_LIMIT);
|
if (r.data.truncated) $('vmsg').textContent += ' · показаны первые ' + fmtSize(EDIT_LIMIT);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (/^(db|sqlite3?|db3)$/.test(ext)) {
|
||||||
|
// The agent opens the database and edits it; one that predates that
|
||||||
|
// gets the file read here, for viewing only.
|
||||||
|
box.innerHTML = '<p class="dv-note">загрузка…</p>';
|
||||||
|
const r = await DBEdit.open(box, path, (op, args) => api(op, args), toast);
|
||||||
|
if (V.path !== path || V.kind !== 'doc') return;
|
||||||
|
if (r && r.ok) return;
|
||||||
|
if (!/неизвестная операция/.test((r && r.error) || '')) {
|
||||||
|
box.innerHTML = '<p class="dv-note">не удалось открыть: ' + esc((r && r.error) || 'ошибка') + '</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$('vmsg').append(' · только просмотр: обновите агента, чтобы править');
|
||||||
|
}
|
||||||
if (size > DOC_CAP) { box.innerHTML = '<p class="dv-note">файл больше ' + fmtSize(DOC_CAP) + ' - для просмотра скачайте его</p>'; return; }
|
if (size > DOC_CAP) { box.innerHTML = '<p class="dv-note">файл больше ' + fmtSize(DOC_CAP) + ' - для просмотра скачайте его</p>'; return; }
|
||||||
box.innerHTML = '<p class="dv-note">загрузка…</p>';
|
box.innerHTML = '<p class="dv-note">загрузка…</p>';
|
||||||
let html;
|
let html;
|
||||||
@@ -921,6 +1206,78 @@
|
|||||||
$('viewer').addEventListener('cancel', ev => { ev.preventDefault(); if (F.open) closeFind(); else closeViewer(); });
|
$('viewer').addEventListener('cancel', ev => { ev.preventDefault(); if (F.open) closeFind(); else closeViewer(); });
|
||||||
$('closev').onclick = closeViewer;
|
$('closev').onclick = closeViewer;
|
||||||
|
|
||||||
|
// ---------- Properties ----------
|
||||||
|
// Owner, group and mode of a file or folder; a folder's can go to all that
|
||||||
|
// is in it too.
|
||||||
|
const P = { path: '', orig: null };
|
||||||
|
const modeStr = m => m.toString(8).padStart(m > 0o777 ? 4 : 3, '0');
|
||||||
|
const permBoxes = () => [...document.querySelectorAll('#propdlg .pp-perm input[data-bit]')];
|
||||||
|
function showMode(m) {
|
||||||
|
permBoxes().forEach(b => b.checked = !!(m & +b.dataset.bit));
|
||||||
|
$('pp-mode').value = modeStr(m);
|
||||||
|
$('pp-mode').classList.remove('bad');
|
||||||
|
}
|
||||||
|
function readMode() {
|
||||||
|
const v = $('pp-mode').value.trim();
|
||||||
|
return /^[0-7]{3,4}$/.test(v) ? parseInt(v, 8) : null;
|
||||||
|
}
|
||||||
|
permBoxes().forEach(b => b.onchange = () => {
|
||||||
|
let m = (readMode() ?? P.orig.mode) & 0o7000;
|
||||||
|
permBoxes().forEach(x => { if (x.checked) m |= +x.dataset.bit; });
|
||||||
|
showMode(m);
|
||||||
|
});
|
||||||
|
$('pp-mode').oninput = () => {
|
||||||
|
const m = readMode();
|
||||||
|
$('pp-mode').classList.toggle('bad', m === null);
|
||||||
|
if (m !== null) permBoxes().forEach(b => b.checked = !!(m & +b.dataset.bit));
|
||||||
|
};
|
||||||
|
$('pp-recursive').onchange = () => $('pp-only').disabled = !$('pp-recursive').checked;
|
||||||
|
|
||||||
|
async function openProps(path) {
|
||||||
|
const r = await api('stat', {path});
|
||||||
|
if (!r.ok) {
|
||||||
|
toast(/неизвестная операция/.test(r.error || '') ? 'свойства недоступны: обновите клиента' : (r.error || 'ошибка'), true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const d = r.data;
|
||||||
|
P.path = path; P.orig = d;
|
||||||
|
$('pp-path').textContent = path;
|
||||||
|
$('pp-info').textContent = (d.is_dir ? 'папка' : 'файл · ' + fmtSize(d.size)) + ' · изменён ' + fmtTime(d.mtime) +
|
||||||
|
(d.link ? ' · ссылка на ' + d.link : '');
|
||||||
|
$('pp-owner').value = d.owner; $('pp-group').value = d.group;
|
||||||
|
$('pp-users').innerHTML = (d.users || []).map(u => '<option value="' + esc(u) + '">').join('');
|
||||||
|
$('pp-groups').innerHTML = (d.groups || []).map(g => '<option value="' + esc(g) + '">').join('');
|
||||||
|
showMode(d.mode);
|
||||||
|
$('pp-rec').hidden = !d.is_dir;
|
||||||
|
$('pp-recursive').checked = false; $('pp-only').value = ''; $('pp-only').disabled = true;
|
||||||
|
if (!$('propdlg').open) openModal($('propdlg'));
|
||||||
|
}
|
||||||
|
$('pp-apply').onclick = async () => {
|
||||||
|
const o = P.orig, mode = readMode();
|
||||||
|
if (mode === null) { toast('права - три или четыре восьмеричные цифры, например 755', true); return; }
|
||||||
|
const rec = o.is_dir && $('pp-recursive').checked;
|
||||||
|
const owner = $('pp-owner').value.trim(), group = $('pp-group').value.trim();
|
||||||
|
const args = {path: P.path};
|
||||||
|
if (mode !== o.mode || rec) args.mode = mode;
|
||||||
|
if (owner && (owner !== o.owner || rec)) args.owner = owner;
|
||||||
|
if (group && (group !== o.group || rec)) args.group = group;
|
||||||
|
if (rec) { args.recursive = true; args.only = $('pp-only').value; }
|
||||||
|
if (Object.keys(args).length === 1) { toast('ничего не изменено'); return; }
|
||||||
|
if (rec && !confirm('Применить к «' + P.path + '» и всему, что внутри?')) return;
|
||||||
|
const btn = $('pp-apply');
|
||||||
|
btn.disabled = true;
|
||||||
|
const r = await api('chmod', args);
|
||||||
|
btn.disabled = false;
|
||||||
|
if (!r.ok) { toast(/неизвестная операция/.test(r.error || '') ? 'обновите клиента' : (r.error || 'ошибка'), true); return; }
|
||||||
|
const c = r.data || {};
|
||||||
|
toast(rec ? 'применено: ' + c.changed + (c.failed ? ', не удалось: ' + c.failed + ' (' + (c.errors || [])[0] + ')' : '') : 'применено', !!c.failed);
|
||||||
|
openProps(P.path);
|
||||||
|
load(cwd);
|
||||||
|
};
|
||||||
|
$('propdlg-close').onclick = () => $('propdlg').close();
|
||||||
|
$('vprops').onclick = () => { if (V.path) openProps(V.path); };
|
||||||
|
$('dirprops').onclick = () => openProps(cwd);
|
||||||
|
|
||||||
async function del(e) {
|
async function del(e) {
|
||||||
if (e.is_dir) {
|
if (e.is_dir) {
|
||||||
if (!confirm('Рекурсивно удалить каталог "'+e.name+'" со всем содержимым?')) return;
|
if (!confirm('Рекурсивно удалить каталог "'+e.name+'" со всем содержимым?')) return;
|
||||||
@@ -933,12 +1290,250 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function move(e) {
|
// ---------- Move or copy ----------
|
||||||
const dst = prompt('Переместить / переименовать в (полный путь):', join(cwd, e.name));
|
// remote: to another machine (to: its id) or an SSH host (to: 'ssh').
|
||||||
if (!dst) return;
|
const M = { src: '', op: 'move', sug: [], sel: -1, seq: 0, remote: false, to: '', auth: 'password', job: null };
|
||||||
const r = await api('move', {src: join(cwd, e.name), dst});
|
const dirCache = new Map(); // machine + folder -> {at, names} of its subfolders
|
||||||
r.ok ? (toast('перемещено'), load(cwd)) : toast(r.error||'ошибка', true);
|
// The machine the folder is on: this one, or the one it goes to.
|
||||||
|
const target = () => !M.remote ? agentID : M.to === 'ssh' ? '' : M.to;
|
||||||
|
// Lists a folder for the suggestions, quietly: no spinner, kept a little.
|
||||||
|
async function subdirs(path) {
|
||||||
|
const on = target();
|
||||||
|
if (!on) return [];
|
||||||
|
const key = on + '\n' + path;
|
||||||
|
const c = dirCache.get(key);
|
||||||
|
if (c && Date.now() - c.at < 30000) return c.names;
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/panel/agents/' + encodeURIComponent(on) + '/fs/list', {
|
||||||
|
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({path})
|
||||||
|
}).then(r => r.json());
|
||||||
|
const names = r.ok ? (r.data.entries || []).filter(e => e.is_dir).map(e => e.name).sort(byNameStr) : [];
|
||||||
|
dirCache.set(key, {at: Date.now(), names});
|
||||||
|
return names;
|
||||||
|
} catch (e) { return []; }
|
||||||
}
|
}
|
||||||
|
const byNameStr = (a, b) => a.localeCompare(b, undefined, {numeric: true, sensitivity: 'base'});
|
||||||
|
function setOp(op) {
|
||||||
|
M.op = op;
|
||||||
|
document.querySelectorAll('#mv-op button').forEach(b => { b.classList.toggle('on', b.dataset.op === op); b.setAttribute('aria-checked', b.dataset.op === op); });
|
||||||
|
goLabel();
|
||||||
|
}
|
||||||
|
function goLabel() {
|
||||||
|
$('mv-go').textContent = (M.op === 'move' ? 'Переместить' : 'Копировать') + (M.remote ? ' туда' : '');
|
||||||
|
}
|
||||||
|
// ---- to another machine, or by SSH ----
|
||||||
|
function lastShip() { try { return JSON.parse(localStorage.getItem('lpra.ship.last') || '{}'); } catch (e) { return {}; } }
|
||||||
|
function setRemote(on) {
|
||||||
|
M.remote = on;
|
||||||
|
$('mv-net').classList.toggle('on', on);
|
||||||
|
$('mv-net').setAttribute('aria-pressed', on);
|
||||||
|
$('mv-remote').classList.toggle('on', on);
|
||||||
|
goLabel();
|
||||||
|
if (on) loadTargets();
|
||||||
|
$('mv-sug').hidden = true;
|
||||||
|
}
|
||||||
|
function setTo(v) {
|
||||||
|
M.to = v;
|
||||||
|
$('mv-ssh').hidden = v !== 'ssh';
|
||||||
|
dirCache.clear();
|
||||||
|
}
|
||||||
|
function setAuth(a) {
|
||||||
|
M.auth = a;
|
||||||
|
document.querySelectorAll('#mv-auth button').forEach(b => b.classList.toggle('on', b.dataset.auth === a));
|
||||||
|
document.querySelectorAll('.mv-a').forEach(el => { el.hidden = el.dataset.for !== a; });
|
||||||
|
}
|
||||||
|
async function loadTargets() {
|
||||||
|
const sel = $('mv-to'), last = lastShip();
|
||||||
|
sel.innerHTML = '<option value="" disabled>загрузка…</option>';
|
||||||
|
let all = [];
|
||||||
|
try { all = await fetch('/api/panel/agents/presence', {cache: 'no-store'}).then(r => r.json()); } catch (e) { /* SSH only */ }
|
||||||
|
sel.innerHTML = '';
|
||||||
|
all.filter(a => a.id !== agentID).forEach(a => {
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = a.id; o.disabled = !a.online;
|
||||||
|
o.textContent = a.name + ' · ' + a.user + '@' + a.hostname + (a.online ? '' : ' (не в сети)');
|
||||||
|
sel.appendChild(o);
|
||||||
|
});
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = 'ssh'; o.textContent = 'SSH-хост…';
|
||||||
|
sel.appendChild(o);
|
||||||
|
const ok = v => [...sel.options].some(x => x.value === v && !x.disabled);
|
||||||
|
const first = [...sel.options].find(x => !x.disabled);
|
||||||
|
sel.value = ok(last.to) ? last.to : first ? first.value : 'ssh';
|
||||||
|
setTo(sel.value);
|
||||||
|
if (last.ssh) { $('mv-host').value = last.ssh.host || ''; $('mv-port').value = last.ssh.port || 22; $('mv-user').value = last.ssh.user || ''; setAuth(last.ssh.auth || 'password'); }
|
||||||
|
}
|
||||||
|
$('mv-net').onclick = () => { if (!M.job) setRemote(!M.remote); };
|
||||||
|
$('mv-to').onchange = ev => { setTo(ev.target.value); suggest(); };
|
||||||
|
document.querySelectorAll('#mv-auth button').forEach(b => b.onclick = () => setAuth(b.dataset.auth));
|
||||||
|
$('mv-keyload').onclick = () => $('mv-keyfile').click();
|
||||||
|
$('mv-keyfile').onchange = () => {
|
||||||
|
const f = $('mv-keyfile').files[0];
|
||||||
|
if (!f) return;
|
||||||
|
if (f.size > 32768) { toast('файл великоват для ключа', true); return; }
|
||||||
|
f.text().then(t => { $('mv-key').value = t.trim() + '\n'; $('mv-keyname').textContent = f.name; });
|
||||||
|
$('mv-keyfile').value = '';
|
||||||
|
};
|
||||||
|
function sshTarget() {
|
||||||
|
const t = {host: $('mv-host').value.trim(), port: Number($('mv-port').value) || 22, user: $('mv-user').value.trim()};
|
||||||
|
if (!t.host || !t.user) return {error: 'укажите хост и пользователя'};
|
||||||
|
if (M.auth === 'password') {
|
||||||
|
if (!$('mv-pw').value) return {error: 'введите пароль'};
|
||||||
|
t.password = $('mv-pw').value;
|
||||||
|
} else if (M.auth === 'key') {
|
||||||
|
if (!/PRIVATE KEY/.test($('mv-key').value)) return {error: 'вставьте закрытый ключ или загрузите файл'};
|
||||||
|
t.key = $('mv-key').value;
|
||||||
|
if ($('mv-pass').value) t.passphrase = $('mv-pass').value;
|
||||||
|
}
|
||||||
|
return {t};
|
||||||
|
}
|
||||||
|
function busy(on) {
|
||||||
|
document.querySelector('#mvdlg .mv').classList.toggle('running', on);
|
||||||
|
$('mv-go').disabled = on;
|
||||||
|
$('mv-prog').hidden = !on;
|
||||||
|
if (on) requestAnimationFrame(() => $('mv-prog').scrollIntoView({block: 'nearest', behavior: 'smooth'}));
|
||||||
|
}
|
||||||
|
// ship sends to the other machine and follows the job to its end.
|
||||||
|
async function ship(dir, name, conflict, move) {
|
||||||
|
const body = {src: M.src, name, dir, conflict, move};
|
||||||
|
const keep = {to: M.to};
|
||||||
|
if (M.to === 'ssh') {
|
||||||
|
const r = sshTarget();
|
||||||
|
if (r.error) { toast(r.error, true); return; }
|
||||||
|
body.ssh = r.t;
|
||||||
|
keep.ssh = {host: r.t.host, port: r.t.port, user: r.t.user, auth: M.auth};
|
||||||
|
} else {
|
||||||
|
if (!M.to) { toast('выберите машину', true); return; }
|
||||||
|
body.to = M.to;
|
||||||
|
keep.ssh = lastShip().ssh;
|
||||||
|
}
|
||||||
|
try { localStorage.setItem('lpra.ship.last', JSON.stringify(keep)); } catch (e) { /* not kept */ }
|
||||||
|
busy(true);
|
||||||
|
$('mv-bar').parentNode.classList.add('wait');
|
||||||
|
$('mv-bar').style.width = '0';
|
||||||
|
$('mv-ptext').textContent = 'подготовка…';
|
||||||
|
const r = await api('ship', body);
|
||||||
|
$('mv-pw').value = ''; $('mv-pass').value = '';
|
||||||
|
if (!r.ok) { busy(false); toast(r.error || 'ошибка', true); return; }
|
||||||
|
const job = M.job = r.data.job;
|
||||||
|
let v = 0, res = null;
|
||||||
|
const t0 = Date.now(), seen = [];
|
||||||
|
while (!res) {
|
||||||
|
let j;
|
||||||
|
try { j = await fetch('/api/panel/fs/job/' + job + '?v=' + v, {cache: 'no-store'}).then(x => x.json()); }
|
||||||
|
catch (e) { await new Promise(ok => setTimeout(ok, 1500)); continue; }
|
||||||
|
v = j.v || 0;
|
||||||
|
const p = j.progress || {};
|
||||||
|
if (p.total > 0 || p.done > 0) {
|
||||||
|
$('mv-bar').parentNode.classList.remove('wait');
|
||||||
|
$('mv-bar').style.width = (p.total ? Math.min(100, p.done / p.total * 100) : 0).toFixed(1) + '%';
|
||||||
|
// Speed over the last few seconds.
|
||||||
|
seen.push([Date.now(), p.done]);
|
||||||
|
while (seen.length > 2 && Date.now() - seen[0][0] > 4000) seen.shift();
|
||||||
|
const dt = (seen[seen.length - 1][0] - seen[0][0]) / 1000, rate = dt > 0.5 ? (seen[seen.length - 1][1] - seen[0][1]) / dt : 0;
|
||||||
|
$('mv-ptext').textContent = fmtSize(p.done) + ' из ' + fmtSize(p.total) + ' · файлов: ' + p.files + (rate ? ' · ' + fmtSize(Math.round(rate)) + '/с' : '');
|
||||||
|
} else if (p.note) {
|
||||||
|
$('mv-ptext').textContent = p.note === 'подсчёт' ? 'подсчёт…' : p.note + '…';
|
||||||
|
}
|
||||||
|
if (j.done) res = j.result;
|
||||||
|
}
|
||||||
|
M.job = null;
|
||||||
|
if (!res.ok) { busy(false); toast(res.error || 'ошибка', true); return; }
|
||||||
|
$('mv-bar').style.width = '100%';
|
||||||
|
const d = res.data || {}, secs = Math.max(1, Math.round((Date.now() - t0) / 1000));
|
||||||
|
const what = move ? 'перенесено' : 'отправлено';
|
||||||
|
toast(what + (d.done != null ? ': ' + d.done : d.files != null ? ': ' + d.files : '') + (d.skipped ? ', оставлено как было: ' + d.skipped : '') + ' · ' + secs + ' с');
|
||||||
|
setTimeout(() => { busy(false); if ($('mvdlg').open) $('mvdlg').close(); }, 400);
|
||||||
|
if (move) load(cwd);
|
||||||
|
}
|
||||||
|
$('mv-stop').onclick = () => {
|
||||||
|
if (M.job) fetch('/api/panel/fs/job/' + M.job + '/cancel', {method: 'POST'});
|
||||||
|
$('mv-ptext').textContent = 'отмена…';
|
||||||
|
};
|
||||||
|
document.querySelectorAll('#mv-op button').forEach(b => b.onclick = () => setOp(b.dataset.op));
|
||||||
|
// Suggestions: the subfolders of what is typed, matching its last part.
|
||||||
|
async function suggest() {
|
||||||
|
const v = $('mv-dir').value, seq = ++M.seq;
|
||||||
|
const i = v.lastIndexOf('/');
|
||||||
|
const parent = i <= 0 ? '/' : v.slice(0, i), pre = v.slice(i + 1).toLowerCase();
|
||||||
|
const names = await subdirs(parent);
|
||||||
|
if (seq !== M.seq) return;
|
||||||
|
M.sug = names.filter(n => n.toLowerCase().startsWith(pre)).slice(0, 60)
|
||||||
|
.map(n => (parent === '/' ? '' : parent) + '/' + n + '/');
|
||||||
|
M.sel = -1;
|
||||||
|
showSug();
|
||||||
|
}
|
||||||
|
function showSug() {
|
||||||
|
const ul = $('mv-sug');
|
||||||
|
ul.innerHTML = '';
|
||||||
|
M.sug.forEach((full, k) => {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
const cut = full.slice(0, -1).lastIndexOf('/') + 1;
|
||||||
|
li.innerHTML = '<span class="muted"></span><span></span>';
|
||||||
|
li.firstChild.textContent = full.slice(0, cut);
|
||||||
|
li.lastChild.textContent = full.slice(cut);
|
||||||
|
if (k === M.sel) li.className = 'sel';
|
||||||
|
li.onmousedown = ev => { ev.preventDefault(); pick(k); };
|
||||||
|
ul.appendChild(li);
|
||||||
|
});
|
||||||
|
ul.hidden = !M.sug.length || document.activeElement !== $('mv-dir');
|
||||||
|
const sel = ul.children[M.sel];
|
||||||
|
if (sel) sel.scrollIntoView({block: 'nearest'});
|
||||||
|
}
|
||||||
|
function pick(k) {
|
||||||
|
$('mv-dir').value = M.sug[k];
|
||||||
|
suggest();
|
||||||
|
}
|
||||||
|
let sugTimer = null;
|
||||||
|
$('mv-dir').addEventListener('input', () => { clearTimeout(sugTimer); sugTimer = setTimeout(suggest, 120); });
|
||||||
|
$('mv-dir').addEventListener('focus', suggest);
|
||||||
|
$('mv-dir').addEventListener('blur', () => setTimeout(() => { $('mv-sug').hidden = true; }, 100));
|
||||||
|
$('mv-dir').addEventListener('keydown', ev => {
|
||||||
|
const open = !$('mv-sug').hidden && M.sug.length;
|
||||||
|
if (ev.key === 'ArrowDown' && open) { ev.preventDefault(); M.sel = (M.sel + 1) % M.sug.length; showSug(); }
|
||||||
|
else if (ev.key === 'ArrowUp' && open) { ev.preventDefault(); M.sel = (M.sel - 1 + M.sug.length) % M.sug.length; showSug(); }
|
||||||
|
else if ((ev.key === 'Tab' || ev.key === 'Enter') && open && (M.sel >= 0 || ev.key === 'Tab')) { ev.preventDefault(); pick(Math.max(0, M.sel)); }
|
||||||
|
else if (ev.key === 'Enter') { ev.preventDefault(); $('mv-go').click(); }
|
||||||
|
else if (ev.key === 'Escape' && open) { ev.preventDefault(); ev.stopPropagation(); $('mv-sug').hidden = true; }
|
||||||
|
});
|
||||||
|
$('mv-name').addEventListener('keydown', ev => { if (ev.key === 'Enter') { ev.preventDefault(); $('mv-go').click(); } });
|
||||||
|
|
||||||
|
function move(e) {
|
||||||
|
M.src = join(cwd, e.name);
|
||||||
|
$('mv-src').textContent = M.src + (e.is_dir ? '/' : '');
|
||||||
|
setOp('move');
|
||||||
|
if (!M.job) { busy(false); setRemote(false); }
|
||||||
|
$('mv-dir').value = cwd === '/' ? '/' : cwd + '/';
|
||||||
|
$('mv-name').value = e.name;
|
||||||
|
$('mv-conflict').value = 'error';
|
||||||
|
dirCache.clear();
|
||||||
|
openModal($('mvdlg'));
|
||||||
|
$('mv-dir').focus();
|
||||||
|
const end = $('mv-dir').value.length;
|
||||||
|
$('mv-dir').setSelectionRange(end, end);
|
||||||
|
}
|
||||||
|
$('mv-go').onclick = async () => {
|
||||||
|
const dir = $('mv-dir').value.trim().replace(/\/+$/, '') || '/';
|
||||||
|
const name = $('mv-name').value.trim();
|
||||||
|
if (!name || name.includes('/')) { toast('имя - без «/»', true); return; }
|
||||||
|
const dst = (dir === '/' ? '' : dir) + '/' + name;
|
||||||
|
const conflict = $('mv-conflict').value, move = M.op === 'move';
|
||||||
|
if (M.remote) { if (!M.job) ship(dir, name, conflict, move); return; }
|
||||||
|
const btn = $('mv-go');
|
||||||
|
btn.disabled = true;
|
||||||
|
let r = await api('copy', {src: M.src, dst, move, conflict});
|
||||||
|
// A client that predates copying still moves, when nothing is in the way.
|
||||||
|
if (!r.ok && /неизвестная операция/.test(r.error || '')) {
|
||||||
|
r = move && conflict === 'error' ? await api('move', {src: M.src, dst}) : {ok: false, error: 'копирование и замена: обновите клиента'};
|
||||||
|
}
|
||||||
|
btn.disabled = false;
|
||||||
|
if (!r.ok) { toast(r.error || 'ошибка', true); return; }
|
||||||
|
const skipped = r.data && r.data.skipped;
|
||||||
|
toast((move ? 'перемещено' : 'скопировано') + (skipped ? ', оставлено как было: ' + skipped : ''));
|
||||||
|
$('mvdlg').close();
|
||||||
|
load(cwd);
|
||||||
|
};
|
||||||
|
$('mvdlg-close').onclick = () => $('mvdlg').close();
|
||||||
|
|
||||||
async function unzip(e) {
|
async function unzip(e) {
|
||||||
const r = await api('unzip', {path: join(cwd, e.name), dest: cwd});
|
const r = await api('unzip', {path: join(cwd, e.name), dest: cwd});
|
||||||
@@ -994,7 +1589,10 @@
|
|||||||
const busy = $('busy');
|
const busy = $('busy');
|
||||||
busy.classList.toggle('up', uploads.length > 0);
|
busy.classList.toggle('up', uploads.length > 0);
|
||||||
busy.title = uploads.length ? 'Показать загружаемые файлы' : '';
|
busy.title = uploads.length ? 'Показать загружаемые файлы' : '';
|
||||||
$('uppct').textContent = pend.length ? Math.floor(100 * sent / total) + '%' + (counted.length > 1 ? ' · ' + (counted.length - pend.length) + '/' + counted.length : '') : '';
|
// The figures slide in while files go and slide out after, keeping the
|
||||||
|
// last text as they go.
|
||||||
|
if (pend.length) $('uppct').textContent = Math.floor(100 * sent / total) + '%' + (counted.length > 1 ? ' · ' + (counted.length - pend.length) + '/' + counted.length : '');
|
||||||
|
$('uppct').classList.toggle('on', pend.length > 0);
|
||||||
const list = $('updlg-list');
|
const list = $('updlg-list');
|
||||||
if (!$('updlg').open) return;
|
if (!$('updlg').open) return;
|
||||||
list.innerHTML = uploads.length ? '' : '<p class="muted">Сейчас ничего не загружается.</p>';
|
list.innerHTML = uploads.length ? '' : '<p class="muted">Сейчас ничего не загружается.</p>';
|
||||||
@@ -1013,7 +1611,7 @@
|
|||||||
list.appendChild(el);
|
list.appendChild(el);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$('busy').onclick = () => { if (!uploads.length) return; $('updlg').showModal(); renderUploads(); };
|
$('busy').onclick = () => { if (!uploads.length) return; openModal($('updlg')); renderUploads(); };
|
||||||
$('updlg-close').onclick = () => $('updlg').close();
|
$('updlg-close').onclick = () => $('updlg').close();
|
||||||
|
|
||||||
// The pieces go on to the client as they come, so the bar follows the
|
// The pieces go on to the client as they come, so the bar follows the
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Порты · {{.Agent.Name}} - LPRA</title>
|
<title>Порты · {{.Agent.Name}} - LPRA</title>
|
||||||
<link rel="stylesheet" href="/static/app.css">
|
<link rel="stylesheet" href="{{asset "app.css"}}">
|
||||||
|
<script src="{{asset "pin.js"}}"></script>
|
||||||
|
<script src="{{asset "scroll.js"}}" defer></script>
|
||||||
<style>
|
<style>
|
||||||
/* One line: fixed narrow fields, the label takes what is left. */
|
/* One line: fixed narrow fields, the label takes what is left. */
|
||||||
.frow { display:flex; align-items:flex-end; gap:12px; flex-wrap:wrap; }
|
.frow { display:flex; align-items:flex-end; gap:12px; flex-wrap:wrap; }
|
||||||
@@ -37,7 +39,7 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div class="brand">LPRA · Порты - {{.Agent.Name}} <span class="muted">({{.Agent.Hostname}})</span></div>
|
<div class="brand"><a class="home" href="/" title="К списку машин">LPRA</a> · Порты - {{.Agent.Name}} <span class="muted">({{.Agent.Hostname}})</span></div>
|
||||||
<nav>
|
<nav>
|
||||||
<a href="/">← Машины</a>
|
<a href="/">← Машины</a>
|
||||||
<a href="/agents/{{.Agent.ID}}/terminal">Терминал</a>
|
<a href="/agents/{{.Agent.ID}}/terminal">Терминал</a>
|
||||||
|
|||||||
@@ -4,21 +4,67 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Вход - LPRA</title>
|
<title>Вход - LPRA</title>
|
||||||
<link rel="stylesheet" href="/static/app.css">
|
<link rel="stylesheet" href="{{asset "app.css"}}">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="center">
|
<div class="center">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h1>Вход в панель</h1>
|
<h1>Вход в панель</h1>
|
||||||
{{if .Error}}<div class="err">{{.Error}}</div>{{end}}
|
{{if .Error}}<div class="err" id="err">{{.Error}}</div>{{else}}<div class="err" id="err" hidden></div>{{end}}
|
||||||
<form method="post" action="/login">
|
<form method="post" action="/login">
|
||||||
<label>Имя пользователя</label>
|
<label for="u">Имя пользователя</label>
|
||||||
<input type="text" name="username" autofocus required>
|
<input type="text" id="u" name="username" value="{{.Username}}" autocomplete="username webauthn" autofocus required>
|
||||||
<label>Пароль</label>
|
<label for="pw">Пароль</label>
|
||||||
<input type="password" name="password" required>
|
<input type="password" id="pw" name="password" autocomplete="current-password" required>
|
||||||
<div style="margin-top:18px"><button type="submit">Войти</button></div>
|
<div style="margin-top:18px"><button type="submit" style="width:100%">Войти</button></div>
|
||||||
</form>
|
</form>
|
||||||
|
<div class="or" id="or" hidden>или</div>
|
||||||
|
<button type="button" class="keybtn btn ghost" id="keylogin" hidden><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="7.5" cy="15.5" r="4.5"/><path d="m21 2-9.6 9.6M15.5 7.5l3 3L22 7l-3-3"/></svg>Войти с ключом доступа</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<script>
|
||||||
|
// Sign in with a passkey: with the name typed, its keys are offered; without
|
||||||
|
// it, the device offers the keys it keeps for this site.
|
||||||
|
(function () {
|
||||||
|
if (!(window.PublicKeyCredential && navigator.credentials && window.isSecureContext)) return;
|
||||||
|
var btn = document.getElementById('keylogin'), err = document.getElementById('err');
|
||||||
|
btn.hidden = false;
|
||||||
|
document.getElementById('or').hidden = false;
|
||||||
|
var enc = function (buf) {
|
||||||
|
var s = '', b = new Uint8Array(buf);
|
||||||
|
for (var i = 0; i < b.length; i++) s += String.fromCharCode(b[i]);
|
||||||
|
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||||
|
};
|
||||||
|
var dec = function (str) {
|
||||||
|
var s = atob(str.replace(/-/g, '+').replace(/_/g, '/') + '==='.slice((str.length + 3) % 4)), b = new Uint8Array(s.length);
|
||||||
|
for (var i = 0; i < s.length; i++) b[i] = s.charCodeAt(i);
|
||||||
|
return b.buffer;
|
||||||
|
};
|
||||||
|
function show(t) { err.textContent = t; err.hidden = !t; }
|
||||||
|
btn.onclick = function () {
|
||||||
|
btn.disabled = true;
|
||||||
|
show('');
|
||||||
|
fetch('/api/login/key/begin', {method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({username: document.getElementById('u').value})})
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (r) {
|
||||||
|
if (!r.ok) throw new Error(r.error);
|
||||||
|
var o = r.options;
|
||||||
|
o.challenge = dec(o.challenge);
|
||||||
|
o.allowCredentials.forEach(function (c) { c.id = dec(c.id); });
|
||||||
|
return navigator.credentials.get({publicKey: o});
|
||||||
|
})
|
||||||
|
.then(function (c) {
|
||||||
|
return fetch('/api/login/key/finish', {method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({id: enc(c.rawId), clientDataJSON: enc(c.response.clientDataJSON),
|
||||||
|
authenticatorData: enc(c.response.authenticatorData), signature: enc(c.response.signature)})})
|
||||||
|
.then(function (r) { return r.json(); });
|
||||||
|
})
|
||||||
|
.then(function (r) { if (!r.ok) throw new Error(r.error); location.href = '/'; })
|
||||||
|
.catch(function (e) { show(e && e.name === 'NotAllowedError' ? 'Вход по ключу отменён.' : (e && e.message) || 'Не вышло.'); })
|
||||||
|
.then(function () { btn.disabled = false; });
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -4,17 +4,58 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Настройки - LPRA</title>
|
<title>Настройки - LPRA</title>
|
||||||
<link rel="stylesheet" href="/static/app.css">
|
<link rel="stylesheet" href="{{asset "app.css"}}">
|
||||||
|
<script src="{{asset "pin.js"}}"></script>
|
||||||
|
<script src="{{asset "scroll.js"}}" defer></script>
|
||||||
|
<style>
|
||||||
|
/* An install command, with a button to copy it at its right. */
|
||||||
|
/* Users: role, machines, password sign-in. */
|
||||||
|
.tablewrap { overflow-x:auto; }
|
||||||
|
table.users th { font-size:12px; font-weight:500; color:var(--muted); text-align:left; white-space:nowrap; }
|
||||||
|
table.users td { vertical-align:middle; }
|
||||||
|
table.users select.u-role { width:auto; height:30px; padding:2px 30px 2px 8px; font-size:13px; }
|
||||||
|
table.users .u-agents { vertical-align:middle; margin-left:4px; }
|
||||||
|
table.users .u-count { font-size:12px; margin-left:2px; }
|
||||||
|
table.users .u-lock { width:16px; height:16px; cursor:pointer; }
|
||||||
|
.u-note { font-size:12px; color:var(--ok); opacity:0; }
|
||||||
|
.u-note.bad { color:var(--danger); }
|
||||||
|
.u-note.fade { animation:u-note 3s ease forwards; }
|
||||||
|
@keyframes u-note { 0%, 70% { opacity:1; } 100% { opacity:0; } }
|
||||||
|
.u-add { margin-top:14px; }
|
||||||
|
.u-pick { display:flex; flex-wrap:wrap; gap:6px; align-items:center; animation:u-in .2s ease; }
|
||||||
|
.u-pick[hidden] { display:none; }
|
||||||
|
.u-pick > .muted:first-child { flex-basis:100%; }
|
||||||
|
.u-chip { display:inline-flex; align-items:center; gap:6px; margin:0; padding:5px 10px; border:1px solid var(--border); border-radius:16px;
|
||||||
|
font-size:13px; color:var(--text); cursor:pointer; transition:border-color .15s, background .15s; }
|
||||||
|
.u-chip:has(input:checked) { border-color:var(--accent); background:#12202f; }
|
||||||
|
.u-chip input { width:14px; height:14px; margin:0; }
|
||||||
|
@keyframes u-in { from { opacity:0; transform:translateY(-4px); } }
|
||||||
|
#uadlg { max-width:480px; }
|
||||||
|
.ua-tools { display:flex; gap:8px; margin-bottom:10px; }
|
||||||
|
.ua-tools input { flex:1; height:32px; }
|
||||||
|
.ua-list { max-height:50vh; overflow:auto; border:1px solid var(--border); border-radius:9px; padding:4px; }
|
||||||
|
.ua-item { display:flex; align-items:center; gap:10px; margin:0; padding:8px 10px; border-radius:7px; cursor:pointer; color:var(--text); font-size:14px; }
|
||||||
|
.ua-item:hover { background:#151a21; }
|
||||||
|
.ua-item[hidden] { display:none; }
|
||||||
|
.ua-item input { width:16px; height:16px; margin:0; }
|
||||||
|
.ua-host { margin-left:auto; font-size:12px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||||
|
.copybox { position:relative; }
|
||||||
|
.copybox pre { padding-right:48px; }
|
||||||
|
.copybox .copy { position:absolute; top:7px; right:7px; width:30px; height:30px; padding:0;
|
||||||
|
display:inline-flex; align-items:center; justify-content:center; background:var(--panel); }
|
||||||
|
.copybox .copy.done { color:var(--ok); border-color:var(--ok); }
|
||||||
|
.codeform { display:flex; flex-wrap:wrap; align-items:center; gap:8px 10px; margin-top:10px; font-size:13px; }
|
||||||
|
.codeform label { margin:0; }
|
||||||
|
.codeform input[type=number] { width:72px; height:30px; padding:4px 8px; }
|
||||||
|
.codeform .ico { display:inline-flex; align-items:center; gap:6px; height:30px; }
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div class="brand">LPRA · Удалённый доступ</div>
|
<div class="brand"><a class="home" href="/" title="К списку машин">LPRA</a> · Удалённый доступ</div>
|
||||||
<nav>
|
<nav>
|
||||||
<a href="/">Машины</a>
|
<a href="/">Машины</a>
|
||||||
<a href="/settings">Настройки</a>
|
<button type="button" class="btn ghost icon acct-btn" id="acct" title="Аккаунт" aria-label="Аккаунт"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/></svg></button>
|
||||||
<form method="post" action="/logout" style="display:inline; margin-left:22px">
|
|
||||||
<button class="btn ghost small" type="submit">Выйти</button>
|
|
||||||
</form>
|
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
@@ -27,14 +68,29 @@
|
|||||||
поставит сервис и зарегистрирует машину.</p>
|
поставит сервис и зарегистрирует машину.</p>
|
||||||
|
|
||||||
<label>Короткая команда (секрет уже вшит в ссылку)</label>
|
<label>Короткая команда (секрет уже вшит в ссылку)</label>
|
||||||
<pre id="short">{{.InstallShort}}</pre>
|
<div class="copybox"><pre id="short">{{.InstallShort}}</pre><button type="button" class="btn ghost small icon sq copy" data-copy="short" title="Скопировать" aria-label="Скопировать"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"/></svg></button></div>
|
||||||
|
|
||||||
|
<form method="post" action="/settings/install/code" class="codeform"
|
||||||
|
onsubmit="return confirm('Сделать новый код? Старая короткая ссылка перестанет работать.')">
|
||||||
|
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||||
|
<label for="codelen">Символов в коде</label>
|
||||||
|
<input type="number" id="codelen" name="len" min="3" max="24" value="{{.CodeLen}}">
|
||||||
|
<button type="submit" class="btn ghost small ico"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-3-6.7L21 8"/><path d="M21 3v5h-5"/></svg>Новый код</button>
|
||||||
|
<span class="muted">латиница и цифры, от 3 до 24; новый код отменяет старую ссылку</span>
|
||||||
|
</form>
|
||||||
|
|
||||||
<details style="margin-top:10px">
|
<details style="margin-top:10px">
|
||||||
<summary class="muted" style="cursor:pointer">Полные варианты (секрет в команде)</summary>
|
<summary class="muted" style="cursor:pointer">Полные варианты (секрет в команде)</summary>
|
||||||
<label>curl</label>
|
<label>curl</label>
|
||||||
<pre id="curl">{{.InstallCurl}}</pre>
|
<div class="copybox"><pre id="curl">{{.InstallCurl}}</pre><button type="button" class="btn ghost small icon sq copy" data-copy="curl" title="Скопировать" aria-label="Скопировать"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"/></svg></button></div>
|
||||||
<label>wget</label>
|
<label>wget</label>
|
||||||
<pre id="wget">{{.InstallWget}}</pre>
|
<div class="copybox"><pre id="wget">{{.InstallWget}}</pre><button type="button" class="btn ghost small icon sq copy" data-copy="wget" title="Скопировать" aria-label="Скопировать"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"/></svg></button></div>
|
||||||
|
{{if .Direct}}
|
||||||
|
<label>Короткая через прямой адрес (если основной недоступен)</label>
|
||||||
|
<div class="copybox"><pre id="dshort">{{.DirectShort}}</pre><button type="button" class="btn ghost small icon sq copy" data-copy="dshort" title="Скопировать" aria-label="Скопировать"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"/></svg></button></div>
|
||||||
|
<label>curl через прямой адрес</label>
|
||||||
|
<div class="copybox"><pre id="dcurl">{{.DirectCurl}}</pre><button type="button" class="btn ghost small icon sq copy" data-copy="dcurl" title="Скопировать" aria-label="Скопировать"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"/></svg></button></div>
|
||||||
|
{{end}}
|
||||||
<p class="muted">Без переменных окружения скрипт спросит секрет интерактивно:
|
<p class="muted">Без переменных окружения скрипт спросит секрет интерактивно:
|
||||||
<code>curl -fsSL {{.Host}}/install.sh | sudo sh</code></p>
|
<code>curl -fsSL {{.Host}}/install.sh | sudo sh</code></p>
|
||||||
</details>
|
</details>
|
||||||
@@ -42,11 +98,18 @@
|
|||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Адрес хоста</h2>
|
<h2>Адрес хоста</h2>
|
||||||
<p class="muted">URL, на который звонят агенты. Подставляется в скрипт установки.</p>
|
<form method="post" action="/settings/host" class="hostform">
|
||||||
<form method="post" action="/settings/host" class="row">
|
|
||||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||||
<input type="text" name="host" value="{{.Host}}" style="max-width:420px">
|
<label for="host">Основной</label>
|
||||||
<button type="submit">Сохранить</button>
|
<input type="text" id="host" name="host" value="{{.Host}}" placeholder="https://lpra.example.com">
|
||||||
|
<p class="muted">URL, на который звонят агенты. Подставляется в скрипт установки.</p>
|
||||||
|
<label for="direct">Прямой (без Cloudflare) - необязательно</label>
|
||||||
|
<input type="text" id="direct" name="direct" value="{{.Direct}}" placeholder="https://direct.lpra.example.com">
|
||||||
|
<p class="muted">Имя, которое ведёт прямо на сервер, мимо прокси. Агенты переходят на него, если основной
|
||||||
|
недоступен 30 секунд, и возвращаются, когда он ответит; уже установленные получат его сами. Установщик
|
||||||
|
качает агента с него, если основной не отвечает. Если хост туннеля ниже не задан, туннель тоже идёт на этот хост.
|
||||||
|
Ключи доступа с основного домена работают на прямом, если он его поддомен (direct.lpra.example.com).</p>
|
||||||
|
<div><button type="submit">Сохранить</button></div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -84,15 +147,38 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Администраторы</h2>
|
<h2 id="users">Пользователи</h2>
|
||||||
<table>
|
<p class="muted" style="font-size:13px">Администратор видит всё. Пользователь - только выданные ему машины, без настроек.
|
||||||
|
«Пароль» - можно ли входить по паролю (выключить можно, когда у человека есть ключ доступа);
|
||||||
|
«Закрепить» - он не сможет сам поменять это в своём аккаунте.</p>
|
||||||
|
<div class="tablewrap"><table class="users">
|
||||||
|
<thead><tr><th>Имя</th><th>Роль</th><th>Вход</th><th title="Вход по паролю">Пароль</th><th title="Не даёт менять вход по паролю самому">Закрепить</th><th></th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range .Admins}}
|
{{range .Admins}}
|
||||||
<tr>
|
<tr data-id="{{.ID}}" data-name="{{.Username}}">
|
||||||
<td>{{.Username}}</td>
|
<td>{{.Username}}{{if eq .ID $.Me.ID}} <span class="muted">(вы)</span>{{end}}</td>
|
||||||
<td style="text-align:right">
|
<td class="nowrap">
|
||||||
|
<select class="u-role" aria-label="Роль">
|
||||||
|
<option value="admin"{{if .IsAdmin}} selected{{end}}>Администратор</option>
|
||||||
|
<option value="user"{{if not .IsAdmin}} selected{{end}}>Пользователь</option>
|
||||||
|
</select>
|
||||||
|
<button type="button" class="btn ghost small icon sq u-agents" title="Машины пользователя" aria-label="Машины пользователя"{{if .IsAdmin}} hidden{{end}}><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg></button>
|
||||||
|
<span class="muted u-count"{{if .IsAdmin}} hidden{{end}}>{{index $.AgentCounts .ID}}</span>
|
||||||
|
</td>
|
||||||
|
<td class="muted">{{with index $.KeyCounts .ID}}ключей: {{.}}{{else}}только пароль{{end}}</td>
|
||||||
|
<td><input type="checkbox" class="sw u-pw" aria-label="Вход по паролю"{{if not (index $.PassOff .ID)}} checked{{end}}{{if not (index $.KeyCounts .ID)}} disabled title="Нет ключей доступа - пароль выключить нельзя"{{end}}></td>
|
||||||
|
<td><input type="checkbox" class="u-lock" aria-label="Закрепить"{{if .PwLocked}} checked{{end}}></td>
|
||||||
|
<td style="text-align:right" class="nowrap">
|
||||||
|
{{if index $.KeyCounts .ID}}
|
||||||
|
<form method="post" action="/settings/admins/keys/reset" style="display:inline"
|
||||||
|
onsubmit="return confirm('Сбросить все ключи доступа {{.Username}}? Вход по паролю снова включится.')">
|
||||||
|
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||||
|
<input type="hidden" name="id" value="{{.ID}}">
|
||||||
|
<button class="btn ghost small" type="submit" title="Удалить все ключи доступа и снова включить вход по паролю">Сбросить ключи</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
<form method="post" action="/settings/admins/delete" style="display:inline"
|
<form method="post" action="/settings/admins/delete" style="display:inline"
|
||||||
onsubmit="return confirm('Удалить администратора {{.Username}}?')">
|
onsubmit="return confirm('Удалить {{.Username}}?')">
|
||||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||||
<input type="hidden" name="id" value="{{.ID}}">
|
<input type="hidden" name="id" value="{{.ID}}">
|
||||||
<button class="btn danger small" type="submit">Удалить</button>
|
<button class="btn danger small" type="submit">Удалить</button>
|
||||||
@@ -101,17 +187,35 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table></div>
|
||||||
<form method="post" action="/settings/admins/add" style="margin-top:14px">
|
<form method="post" action="/settings/admins/add" class="u-add">
|
||||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<input type="text" name="username" placeholder="логин" style="max-width:200px" required>
|
<input type="text" name="username" placeholder="логин" style="max-width:200px" required autocomplete="off">
|
||||||
<input type="password" name="password" placeholder="пароль (мин. 8)" style="max-width:200px" required>
|
<input type="password" name="password" placeholder="пароль (мин. 8)" style="max-width:200px" required autocomplete="new-password">
|
||||||
|
<select name="role" id="newrole" style="max-width:180px">
|
||||||
|
<option value="admin">Администратор</option>
|
||||||
|
<option value="user">Пользователь</option>
|
||||||
|
</select>
|
||||||
<button type="submit">Добавить</button>
|
<button type="submit">Добавить</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="u-pick" id="newagents" hidden>
|
||||||
|
<div class="muted" style="font-size:13px;margin:10px 0 6px">Машины, к которым у него будет доступ:</div>
|
||||||
|
{{range .AllAgents}}<label class="u-chip"><input type="checkbox" name="agents" value="{{.ID}}"><span>{{.Name}}</span></label>{{else}}<span class="muted">машин пока нет</span>{{end}}
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<dialog id="uadlg" tabindex="-1">
|
||||||
|
<div class="dlg-h"><strong style="flex:1">Машины пользователя <span id="uaname"></span></strong>
|
||||||
|
<button type="button" class="btn ghost small icon close x" id="uaclose" title="Закрыть" aria-label="Закрыть"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button></div>
|
||||||
|
<div class="dlg-b">
|
||||||
|
<div class="ua-tools"><input type="text" id="uafilter" placeholder="поиск" autocomplete="off"><button type="button" class="btn ghost small" id="uaall">Все</button><button type="button" class="btn ghost small" id="uanone">Ни одной</button></div>
|
||||||
|
<div class="ua-list" id="ualist"></div>
|
||||||
|
<div class="row" style="justify-content:flex-end;margin-top:14px"><button type="button" class="btn ghost" id="uacancel">Отмена</button><button type="button" id="uasave">Сохранить</button></div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Firewall (ufw)</h2>
|
<h2>Firewall (ufw)</h2>
|
||||||
{{if not .UfwInstalled}}
|
{{if not .UfwInstalled}}
|
||||||
@@ -176,5 +280,33 @@
|
|||||||
|
|
||||||
<p class="muted" style="font-size:12px; text-align:center">LPRA сервер · версия {{.ServerVersion}}</p>
|
<p class="muted" style="font-size:12px; text-align:center">LPRA сервер · версия {{.ServerVersion}}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<script>
|
||||||
|
// Copy an install command: the clipboard API on https, the old way otherwise.
|
||||||
|
(function () {
|
||||||
|
var CHECK = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>';
|
||||||
|
function copy(text) {
|
||||||
|
if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(text);
|
||||||
|
var ta = document.createElement('textarea');
|
||||||
|
ta.value = text;
|
||||||
|
ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0';
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.select();
|
||||||
|
var ok = document.execCommand('copy');
|
||||||
|
ta.remove();
|
||||||
|
return ok ? Promise.resolve() : Promise.reject(new Error('нет доступа к буферу'));
|
||||||
|
}
|
||||||
|
document.querySelectorAll('.copybox .copy').forEach(function (b) {
|
||||||
|
var icon = b.innerHTML;
|
||||||
|
b.onclick = function () {
|
||||||
|
copy(document.getElementById(b.dataset.copy).textContent.trim()).then(function () {
|
||||||
|
b.innerHTML = CHECK; b.classList.add('done'); b.title = 'Скопировано';
|
||||||
|
setTimeout(function () { b.innerHTML = icon; b.classList.remove('done'); b.title = 'Скопировать'; }, 1500);
|
||||||
|
}, function (e) { alert('Не скопировалось: ' + e.message); });
|
||||||
|
};
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<script src="{{asset "users.js"}}"></script>
|
||||||
|
<script src="{{asset "account.js"}}"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Настройка - LPRA</title>
|
<title>Настройка - LPRA</title>
|
||||||
<link rel="stylesheet" href="/static/app.css">
|
<link rel="stylesheet" href="{{asset "app.css"}}">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="center">
|
<div class="center">
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Статус · {{.Agent.Name}} - LPRA</title>
|
<title>Статус · {{.Agent.Name}} - LPRA</title>
|
||||||
<link rel="stylesheet" href="/static/app.css">
|
<link rel="stylesheet" href="{{asset "app.css"}}">
|
||||||
|
<script src="{{asset "pin.js"}}"></script>
|
||||||
|
<script src="{{asset "scroll.js"}}" defer></script>
|
||||||
<style>
|
<style>
|
||||||
.meters { display:grid; grid-template-columns:repeat(auto-fit, minmax(240px, 1fr)); gap:20px; margin-bottom:20px; }
|
.meters { display:grid; grid-template-columns:repeat(auto-fit, minmax(240px, 1fr)); gap:20px; margin-bottom:20px; }
|
||||||
.meters .card { margin:0; }
|
.meters .card { margin:0; }
|
||||||
@@ -42,7 +44,7 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div class="brand">LPRA · Статус - {{.Agent.Name}} <span class="muted">({{.Agent.Hostname}})</span></div>
|
<div class="brand"><a class="home" href="/" title="К списку машин">LPRA</a> · Статус - {{.Agent.Name}} <span class="muted">({{.Agent.Hostname}})</span></div>
|
||||||
<nav>
|
<nav>
|
||||||
<a href="/">← Машины</a>
|
<a href="/">← Машины</a>
|
||||||
<a href="/agents/{{.Agent.ID}}/terminal">Терминал</a>
|
<a href="/agents/{{.Agent.ID}}/terminal">Терминал</a>
|
||||||
|
|||||||
@@ -4,17 +4,145 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Терминал · {{.Agent.Name}} - LPRA</title>
|
<title>Терминал · {{.Agent.Name}} - LPRA</title>
|
||||||
<link rel="stylesheet" href="/static/app.css">
|
<link rel="stylesheet" href="{{asset "app.css"}}">
|
||||||
<link rel="stylesheet" href="/static/xterm.css">
|
<script src="{{asset "pin.js"}}"></script>
|
||||||
|
<script src="{{asset "scroll.js"}}" defer></script>
|
||||||
|
<link rel="stylesheet" href="{{asset "xterm.css"}}">
|
||||||
<style>
|
<style>
|
||||||
#status.st-ok { color:#37c871; }
|
#status.st-ok { color:#37c871; }
|
||||||
#status.st-err { color:#ff5c5c; }
|
#status.st-err { color:#ff5c5c; }
|
||||||
|
#status { display:inline-block; vertical-align:bottom; min-width:0; max-width:42vw; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||||
|
/* SSH: the badge on a pane, and the dialog to connect. */
|
||||||
|
.badge-ssh { flex:none; font-size:10px; font-weight:600; letter-spacing:.04em; padding:1px 5px; border-radius:4px;
|
||||||
|
background:#12202f; color:var(--accent); border:1px solid #1f3a57; }
|
||||||
|
dialog.sshdlg { max-width:460px; }
|
||||||
|
.sshdlg .dlg-h .ico { color:var(--accent); display:inline-flex; }
|
||||||
|
.sshdlg .dlg-b { display:grid; gap:12px; }
|
||||||
|
.sshdlg .f { display:grid; gap:5px; margin:0; }
|
||||||
|
.sshdlg .f > span { font-size:12px; color:var(--muted); }
|
||||||
|
.sshdlg .frow { display:flex; gap:10px; align-items:end; }
|
||||||
|
.sshdlg .frow .grow { flex:1; min-width:0; }
|
||||||
|
.sshdlg .frow .port { width:92px; flex:none; }
|
||||||
|
.sshdlg input, .sshdlg select, .sshdlg textarea { width:100%; box-sizing:border-box; }
|
||||||
|
.sshdlg textarea { font:12px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; resize:vertical; min-height:90px;
|
||||||
|
background:var(--bg, #0c0f13); color:var(--text); border:1px solid var(--border); border-radius:8px; padding:8px 10px; }
|
||||||
|
.sshdlg textarea:focus { border-color:var(--accent); outline:none; }
|
||||||
|
.sshdlg .auth { display:grid; gap:8px; animation:auth-in .18s ease; }
|
||||||
|
.sshdlg .auth[hidden] { display:none; }
|
||||||
|
@keyframes auth-in { from { opacity:0; transform:translateY(-4px); } }
|
||||||
|
.sshdlg p.auth { margin:0; font-size:13px; }
|
||||||
|
.sshdlg .keyname { font-size:12px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; align-self:center; }
|
||||||
|
.sshdlg .seg { display:flex; padding:3px; gap:3px; background:#0c0f13; border:1px solid var(--border); border-radius:9px; }
|
||||||
|
.sshdlg .seg button { flex:1; padding:6px 8px; font-size:13px; background:transparent; color:var(--muted); border:0; border-radius:6px;
|
||||||
|
transition:background .18s, color .18s; }
|
||||||
|
.sshdlg .seg button:hover { color:var(--text); }
|
||||||
|
.sshdlg .seg button.on { background:#12202f; color:var(--text); box-shadow:inset 0 0 0 1px #1f3a57; }
|
||||||
|
.sshdlg .sshmsg { margin:0; min-height:18px; font-size:13px; color:var(--danger); }
|
||||||
|
.sshdlg .row.end { display:flex; justify-content:flex-end; gap:8px; }
|
||||||
|
@media (max-width:520px) { .sshdlg .seg button { font-size:12px; padding:6px 4px; } }
|
||||||
|
/* The page is the terminal: bar, tabs, then the terminals to the bottom. */
|
||||||
|
body { height:100vh; display:flex; flex-direction:column; overflow:hidden; }
|
||||||
|
header.topbar { flex:none; }
|
||||||
|
/* Tabs: folded away while there is one, sliding in with the second. */
|
||||||
|
.tabs { flex:none; display:flex; align-items:flex-end; gap:4px; padding:0 16px; max-height:0; opacity:0; overflow:hidden;
|
||||||
|
transition:max-height .28s ease, opacity .22s ease, padding .28s ease; }
|
||||||
|
body.multi .tabs, body.multi-hint .tabs { max-height:48px; opacity:1; padding-top:10px; }
|
||||||
|
.tablist { position:relative; display:flex; gap:4px; min-width:0; overflow-x:auto; scrollbar-width:none; }
|
||||||
|
.tablist::-webkit-scrollbar { display:none; }
|
||||||
|
.tab { display:flex; align-items:center; gap:8px; flex:none; min-width:0; height:34px; padding:0 6px 0 12px; box-sizing:border-box;
|
||||||
|
border:1px solid var(--border); border-bottom:0; border-radius:9px 9px 0 0; background:#11151b; color:var(--muted);
|
||||||
|
font-size:13px; user-select:none; cursor:default; overflow:hidden; white-space:nowrap;
|
||||||
|
transition:background .18s, color .18s, width .22s ease, max-width .22s ease, opacity .2s, padding .22s ease; }
|
||||||
|
.tab:hover { color:var(--text); background:#151a21; }
|
||||||
|
.tab.on { background:var(--panel); color:var(--text); }
|
||||||
|
.tab.enter, .tab.leave { max-width:0; opacity:0; padding:0; }
|
||||||
|
.tab.dragging { opacity:.3; }
|
||||||
|
.tab-title { flex:1; overflow:hidden; text-overflow:ellipsis; min-width:0; }
|
||||||
|
.tab-edit { width:auto; min-width:60px; max-width:150px; height:24px; padding:2px 6px; font-size:13px; }
|
||||||
|
.tab-x, .pane-x { flex:none; width:20px; height:20px; padding:0; display:inline-flex; align-items:center; justify-content:center;
|
||||||
|
background:transparent; border:0; border-radius:5px; color:var(--muted); opacity:.6; transition:opacity .15s, background .15s, color .15s; }
|
||||||
|
.tab-x:hover, .pane-x:hover { opacity:1; background:#3a1d1d; color:var(--danger); }
|
||||||
|
.tab-add { flex:none; width:34px; height:34px; padding:0; display:inline-flex; align-items:center; justify-content:center;
|
||||||
|
border:1px solid var(--border); border-bottom:0; border-radius:9px 9px 0 0; background:transparent; color:var(--muted);
|
||||||
|
font-size:18px; line-height:1; transition:background .18s, color .18s; }
|
||||||
|
.tab-add:hover { background:#151a21; color:var(--text); }
|
||||||
|
.tabmark { position:absolute; top:6px; bottom:0; width:3px; margin-left:-1px; border-radius:2px; background:var(--accent);
|
||||||
|
pointer-events:none; transition:left .12s ease; }
|
||||||
|
.tabmark[hidden] { display:none; }
|
||||||
|
.dot { width:7px; height:7px; border-radius:50%; background:#555d6a; flex:none; transition:background .3s; }
|
||||||
|
.dot.ok { background:var(--ok); } .dot.err { background:var(--danger); padding:0; border:0; margin:0; } .dot.wait { background:#e6a23c; }
|
||||||
|
/* The terminals' area. */
|
||||||
|
.area { position:relative; flex:1; min-height:0; margin:12px 16px 16px; border:1px solid var(--border); border-radius:10px; background:#0c0f13; overflow:hidden; }
|
||||||
|
body.multi .area { margin-top:0; border-top-left-radius:0; }
|
||||||
|
.float-add { position:absolute; top:8px; right:10px; z-index:6; width:28px; height:28px; padding:0; display:inline-flex;
|
||||||
|
align-items:center; justify-content:center; font-size:17px; line-height:1; color:var(--text);
|
||||||
|
background:transparent; border:1px solid var(--border); opacity:.28; transition:opacity .25s ease, background .25s ease, top .25s ease, right .25s ease; }
|
||||||
|
/* One tab split in panes: below the panes' title bars, clear of their close buttons. */
|
||||||
|
body.split1 .float-add { top:42px; right:16px; }
|
||||||
|
.float-add:hover, .float-add:focus-visible { opacity:1; background:var(--panel); }
|
||||||
|
body.multi .float-add { opacity:0; pointer-events:none; }
|
||||||
|
.tabview { position:absolute; inset:8px; opacity:0; visibility:hidden; transform:translateY(4px) scale(.995);
|
||||||
|
transition:opacity .22s ease, transform .22s ease, visibility 0s .22s; }
|
||||||
|
.tabview.on { opacity:1; visibility:visible; transform:none; transition:opacity .22s ease, transform .22s ease, visibility 0s; }
|
||||||
|
.pane { position:absolute; display:flex; flex-direction:column; border-radius:7px; overflow:hidden;
|
||||||
|
transition:left .26s ease, top .26s ease, width .26s ease, height .26s ease, border-color .2s; }
|
||||||
|
.tabview.instant .pane, .pane.instant { transition:none; }
|
||||||
|
.pane-head { display:none; }
|
||||||
|
.tabview.split .pane { border:1px solid var(--border); }
|
||||||
|
.tabview.split .pane.active { border-color:rgba(79,157,255,.7); }
|
||||||
|
.tabview.split .pane-head { display:flex; align-items:center; gap:7px; flex:none; height:26px; padding:0 4px 0 9px;
|
||||||
|
background:#11151b; border-bottom:1px solid var(--border); font-size:12px; color:var(--muted); cursor:grab; user-select:none; }
|
||||||
|
.tabview.split .pane.active .pane-head { color:var(--text); }
|
||||||
|
.pane-title { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||||
|
.pane-body { flex:1; min-height:0; padding:4px 0 0 4px; }
|
||||||
|
/* A lone terminal's scrollbar sits in the margin by the frame, right of
|
||||||
|
the corner "+", not over the text. */
|
||||||
|
body:not(.touch) .tabview:not(.split) .pane { overflow:visible; }
|
||||||
|
body:not(.touch) .tabview:not(.split) .xterm .xterm-viewport { right:-7px; }
|
||||||
|
.pane.dragging { opacity:.45; }
|
||||||
|
.gutter { position:absolute; z-index:3; }
|
||||||
|
.gutter.c { cursor:col-resize; } .gutter.r { cursor:row-resize; }
|
||||||
|
.gutter::after { content:""; position:absolute; inset:0; border-radius:3px; background:var(--accent); opacity:0; transition:opacity .15s; }
|
||||||
|
.gutter:hover::after, .gutter.drag::after { opacity:.55; }
|
||||||
|
.hint { position:absolute; z-index:5; display:flex; align-items:center; justify-content:center; text-align:center; padding:8px;
|
||||||
|
border:2px solid var(--accent); border-radius:9px; background:rgba(79,157,255,.14); color:var(--text); font-size:13px;
|
||||||
|
pointer-events:none; transition:left .14s ease, top .14s ease, width .14s ease, height .14s ease; }
|
||||||
|
.hint.bad { border-color:var(--danger); background:rgba(255,92,92,.12); }
|
||||||
|
.hint[hidden] { display:none; }
|
||||||
|
.drag-ghost { position:fixed; left:0; top:0; z-index:60; pointer-events:none; padding:7px 12px; border:1px solid var(--accent);
|
||||||
|
border-radius:8px; background:var(--panel); color:var(--text); font-size:13px; white-space:nowrap;
|
||||||
|
box-shadow:0 12px 30px rgba(0,0,0,.55); transition:transform .04s linear; }
|
||||||
|
.drag-ghost[data-hint]:not([data-hint=""])::after { content:attr(data-hint); display:block; margin-top:3px; font-size:11px; color:var(--muted); }
|
||||||
|
body.dnd, body.dnd * { cursor:grabbing !important; }
|
||||||
|
.empty { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:12px; color:var(--muted); }
|
||||||
|
.empty[hidden] { display:none; }
|
||||||
|
/* The "+" menu: a terminal here, or on another machine. */
|
||||||
|
.addmenu { position:fixed; z-index:50; min-width:270px; max-width:340px; max-height:70vh; overflow:auto; padding:6px;
|
||||||
|
background:var(--panel); border:1px solid var(--border); border-radius:10px; box-shadow:0 14px 36px rgba(0,0,0,.55);
|
||||||
|
animation:menu-in .14s ease; }
|
||||||
|
.addmenu[hidden] { display:none; }
|
||||||
|
@keyframes menu-in { from { opacity:0; transform:translateY(-4px); } }
|
||||||
|
.addmenu .item { display:flex; align-items:center; gap:10px; width:100%; padding:8px 10px; border-radius:7px; text-align:left;
|
||||||
|
background:transparent; color:var(--text); font-weight:400; }
|
||||||
|
.addmenu .item:hover:not(:disabled) { background:#12202f; }
|
||||||
|
.addmenu .item:disabled { opacity:.45; cursor:not-allowed; }
|
||||||
|
.addmenu .item b { display:block; font-weight:500; font-size:14px; }
|
||||||
|
.addmenu .item small { display:block; color:var(--muted); font-size:12px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:240px; }
|
||||||
|
.addmenu .ico { width:22px; text-align:center; color:var(--accent); font-size:16px; flex:none; }
|
||||||
|
.addmenu .chev { margin-left:auto; color:var(--muted); }
|
||||||
|
.addmenu .mlist { border-top:1px solid var(--border); margin-top:4px; padding-top:4px; }
|
||||||
/* Touch screens: the page is as tall as what the on-screen keyboard leaves,
|
/* Touch screens: the page is as tall as what the on-screen keyboard leaves,
|
||||||
with a row of extra keys at the bottom, the way Termux does it. */
|
with a row of extra keys at the bottom, the way Termux does it. Tabs are
|
||||||
|
smaller, and there is no splitting. */
|
||||||
.keys { display:none; }
|
.keys { display:none; }
|
||||||
body.touch { position:fixed; left:0; right:0; top:0; height:100%; display:flex; flex-direction:column; overflow:hidden; }
|
body.touch { position:fixed; left:0; right:0; top:0; height:100%; }
|
||||||
body.touch .wrap { flex:1; min-height:0; display:flex; width:100%; margin:0; padding:6px; }
|
body.touch .area { margin:6px; }
|
||||||
body.touch #terminal { flex:1; min-width:0; height:auto; }
|
body.touch.multi .area { margin-top:0; }
|
||||||
|
body.touch .tabs { padding-left:6px; padding-right:6px; }
|
||||||
|
body.touch.multi .tabs { padding-top:6px; max-height:38px; }
|
||||||
|
body.touch .tab { height:28px; font-size:12px; padding:0 2px 0 8px; gap:6px; }
|
||||||
|
body.touch .tab-add { width:30px; height:28px; }
|
||||||
|
body.touch .tabview { inset:4px; }
|
||||||
body.touch .keys {
|
body.touch .keys {
|
||||||
display:grid; grid-template-columns:repeat(7, 1fr); gap:4px; flex:none;
|
display:grid; grid-template-columns:repeat(7, 1fr); gap:4px; flex:none;
|
||||||
padding:4px 4px calc(4px + env(safe-area-inset-bottom)); background:var(--panel); border-top:1px solid var(--border);
|
padding:4px 4px calc(4px + env(safe-area-inset-bottom)); background:var(--panel); border-top:1px solid var(--border);
|
||||||
@@ -33,24 +161,31 @@
|
|||||||
.selmenu[hidden] { display:none; }
|
.selmenu[hidden] { display:none; }
|
||||||
.selmenu button { width:42px; height:42px; padding:0; display:inline-flex; align-items:center; justify-content:center;
|
.selmenu button { width:42px; height:42px; padding:0; display:inline-flex; align-items:center; justify-content:center;
|
||||||
touch-action:manipulation; -webkit-tap-highlight-color:transparent; }
|
touch-action:manipulation; -webkit-tap-highlight-color:transparent; }
|
||||||
body.touch #terminal { -webkit-user-select:none; user-select:none; -webkit-touch-callout:none; }
|
body.touch .area { -webkit-user-select:none; user-select:none; -webkit-touch-callout:none; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div class="brand">LPRA · Терминал - {{.Agent.Name}} <span class="muted">({{.Agent.TerminalUser}}@{{.Agent.Hostname}})</span></div>
|
<div class="brand"><a class="home" href="/" title="К списку машин">LPRA</a> · Терминал - {{.Agent.Name}} <span class="muted">({{.Agent.TerminalUser}}@{{.Agent.Hostname}})</span></div>
|
||||||
<nav>
|
<nav>
|
||||||
<a href="/">← Машины</a>
|
<a href="/">← Машины</a>
|
||||||
<a href="/agents/{{.Agent.ID}}/files">Файлы</a>
|
<a href="/agents/{{.Agent.ID}}/files">Файлы</a>
|
||||||
<a href="/agents/{{.Agent.ID}}/forwards">Порты</a>
|
<a href="/agents/{{.Agent.ID}}/forwards">Порты</a>
|
||||||
<a href="/agents/{{.Agent.ID}}/status">Статус</a>
|
<a href="/agents/{{.Agent.ID}}/status">Статус</a>
|
||||||
<a href="/agents/{{.Agent.ID}}/edit">Изменить</a>
|
<a href="/agents/{{.Agent.ID}}/edit">Изменить</a>
|
||||||
<span id="status" class="muted" style="margin-left:28px">подключение…</span>
|
<span id="status" class="muted" style="margin-left:28px"></span>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
<div class="wrap" style="max-width:none">
|
<div class="tabs" id="tabs">
|
||||||
<div id="terminal"></div>
|
<div class="tablist" id="tablist"><div class="tabmark" id="tabmark" hidden></div></div>
|
||||||
|
<button type="button" class="tab-add" id="tabadd" title="Новый терминал" aria-label="Новый терминал">+</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="area" id="area">
|
||||||
|
<button type="button" class="float-add" id="floatadd" title="Новый терминал" aria-label="Новый терминал">+</button>
|
||||||
|
<div class="empty" id="empty" hidden>Нет открытых терминалов<button type="button" id="emptyadd">Новый терминал</button></div>
|
||||||
|
<div class="hint" id="hint" hidden></div>
|
||||||
|
</div>
|
||||||
|
<div class="addmenu" id="addmenu" hidden></div>
|
||||||
<div class="selmenu" id="selmenu" hidden>
|
<div class="selmenu" id="selmenu" hidden>
|
||||||
<button type="button" data-a="copy" title="Копировать" aria-label="Копировать">
|
<button type="button" data-a="copy" title="Копировать" aria-label="Копировать">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"/></svg>
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"/></svg>
|
||||||
@@ -82,147 +217,15 @@
|
|||||||
<button type="button" data-k="pgdn">PGDN</button>
|
<button type="button" data-k="pgdn">PGDN</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/xterm.js"></script>
|
<script src="{{asset "xterm.js"}}"></script>
|
||||||
<script src="/static/addon-fit.js"></script>
|
<script src="{{asset "addon-fit.js"}}"></script>
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
window.TERM_PAGE = { agent: { id: {{.Agent.ID}}, name: {{.Agent.Name}}, host: {{.Agent.Hostname}}, user: {{.Agent.TerminalUser}} } };
|
||||||
const agentID = {{.Agent.ID}};
|
// Touch helpers, for the terminal being typed in: the extra keys with their
|
||||||
const statusEl = document.getElementById('status');
|
// CTRL / ALT, and selecting and copying with a long press.
|
||||||
|
window.TermKeys = (function () {
|
||||||
const touch = window.matchMedia('(pointer: coarse)').matches;
|
const touch = window.matchMedia('(pointer: coarse)').matches;
|
||||||
if (touch) document.body.classList.add('touch');
|
const cur = () => window.TermPane && window.TermPane.active();
|
||||||
function setStatus(t, cls) { statusEl.textContent = t; statusEl.className = cls || ''; }
|
|
||||||
|
|
||||||
const term = new Terminal({
|
|
||||||
cursorBlink: true,
|
|
||||||
fontSize: touch ? 13 : 14,
|
|
||||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
|
||||||
theme: { background: '#0c0f13' }
|
|
||||||
});
|
|
||||||
const fit = new FitAddon.FitAddon();
|
|
||||||
term.loadAddon(fit);
|
|
||||||
term.open(document.getElementById('terminal'));
|
|
||||||
fit.fit();
|
|
||||||
|
|
||||||
function bytesToB64(bytes) {
|
|
||||||
let bin = '';
|
|
||||||
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
|
|
||||||
return btoa(bin);
|
|
||||||
}
|
|
||||||
function b64ToBytes(b64) {
|
|
||||||
const bin = atob(b64);
|
|
||||||
const u = new Uint8Array(bin.length);
|
|
||||||
for (let i = 0; i < bin.length; i++) u[i] = bin.charCodeAt(i);
|
|
||||||
return u;
|
|
||||||
}
|
|
||||||
|
|
||||||
const base = '/api/panel/term/';
|
|
||||||
let sid = null;
|
|
||||||
let closed = false;
|
|
||||||
let connected = false;
|
|
||||||
|
|
||||||
function post(path, body) {
|
|
||||||
return fetch(path, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: body ? { 'Content-Type': 'application/json' } : {},
|
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
|
||||||
keepalive: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function open() {
|
|
||||||
try {
|
|
||||||
const r = await post(base + encodeURIComponent(agentID) + '/open');
|
|
||||||
if (!r.ok) {
|
|
||||||
const body = await r.text().catch(() => '');
|
|
||||||
setStatus('ошибка открытия (' + r.status + ')', 'st-err');
|
|
||||||
term.write('\r\n\x1b[31m[не удалось открыть сессию: HTTP ' + r.status + ']\x1b[0m\r\n');
|
|
||||||
if (body) term.write(body.slice(0, 300).replace(/\n/g, '\r\n') + '\r\n');
|
|
||||||
if (r.status === 404) term.write('\x1b[33mМашина не найдена на сервере - возможно, устаревший бинарник сервера без маршрута /open.\x1b[0m\r\n');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const j = await r.json();
|
|
||||||
sid = j.session_id;
|
|
||||||
setStatus('ожидание агента…');
|
|
||||||
sendResize();
|
|
||||||
pollLoop();
|
|
||||||
} catch (e) {
|
|
||||||
setStatus('ошибка сети', 'st-err');
|
|
||||||
term.write('\r\n\x1b[31m[ошибка сети: ' + (e && e.message ? e.message : e) + ']\x1b[0m\r\n');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pollLoop() {
|
|
||||||
while (!closed && sid) {
|
|
||||||
let r;
|
|
||||||
try {
|
|
||||||
r = await fetch(base + sid + '/out', { cache: 'no-store' });
|
|
||||||
} catch (e) {
|
|
||||||
// transient network hiccup; brief pause then retry
|
|
||||||
await new Promise(res => setTimeout(res, 1000));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!r.ok) { finish('[соединение закрыто]'); return; }
|
|
||||||
const j = await r.json();
|
|
||||||
if (j.closed) { finish('[сессия завершена]'); return; }
|
|
||||||
if (j.frames) {
|
|
||||||
for (const f of j.frames) {
|
|
||||||
if (f.exit) { finish('[сессия завершена]'); return; }
|
|
||||||
if (f.data) {
|
|
||||||
if (!connected) { connected = true; setStatus('подключено', 'st-ok'); }
|
|
||||||
term.write(b64ToBytes(f.data));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function finish(msg) {
|
|
||||||
if (closed) return;
|
|
||||||
closed = true;
|
|
||||||
setStatus('соединение закрыто', 'muted');
|
|
||||||
term.write('\r\n\x1b[33m' + msg + '\x1b[0m\r\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Input goes out one request at a time, so on a slow link a keystroke can
|
|
||||||
// never overtake the one before it; what is typed meanwhile is sent together
|
|
||||||
// next. A request that fails goes again under the same number, and the
|
|
||||||
// server drops one it already has.
|
|
||||||
let inSeq = 0, inBusy = false;
|
|
||||||
const inQueue = [];
|
|
||||||
function queueIn(m) {
|
|
||||||
if (closed || !sid) return;
|
|
||||||
const last = inQueue[inQueue.length - 1];
|
|
||||||
if (last && last.t === 'i' && m.t === 'i') last.text += m.text;
|
|
||||||
else if (last && last.t === 'r' && m.t === 'r') inQueue[inQueue.length - 1] = m;
|
|
||||||
else inQueue.push(m);
|
|
||||||
pumpIn();
|
|
||||||
}
|
|
||||||
async function pumpIn() {
|
|
||||||
if (inBusy || !inQueue.length) return;
|
|
||||||
inBusy = true;
|
|
||||||
while (inQueue.length && !closed) {
|
|
||||||
const m = inQueue.shift();
|
|
||||||
const body = m.t === 'i'
|
|
||||||
? { t: 'i', d: bytesToB64(new TextEncoder().encode(m.text)), seq: ++inSeq }
|
|
||||||
: { t: 'r', cols: m.cols, rows: m.rows, seq: ++inSeq };
|
|
||||||
for (let wait = 300; !closed; wait = Math.min(wait * 2, 5000)) {
|
|
||||||
let r = null;
|
|
||||||
try {
|
|
||||||
r = await fetch(base + sid + '/in', {
|
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
|
|
||||||
});
|
|
||||||
} catch (e) { /* no answer: send it again */ }
|
|
||||||
if (r && r.status < 500) break; // taken, or refused for good
|
|
||||||
await new Promise(res => setTimeout(res, wait));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
inBusy = false;
|
|
||||||
}
|
|
||||||
function send(data) { queueIn({ t: 'i', text: data }); }
|
|
||||||
// What the keyboard types gets the CTRL / ALT of the extra keys, if armed.
|
|
||||||
term.onData(function (data) { send(withMods(data)); });
|
|
||||||
|
|
||||||
// --- Extra keys (touch screens) ---
|
|
||||||
// CTRL and ALT: a tap arms the key for the next key press, a long press
|
// CTRL and ALT: a tap arms the key for the next key press, a long press
|
||||||
// keeps it on until tapped again.
|
// keeps it on until tapped again.
|
||||||
const mods = { ctrl: 0, alt: 0 }; // 0 off, 1 next key, 2 locked
|
const mods = { ctrl: 0, alt: 0 }; // 0 off, 1 next key, 2 locked
|
||||||
@@ -255,7 +258,7 @@
|
|||||||
// xterm's modifier parameter: 1 + shift(1) + alt(2) + ctrl(4).
|
// xterm's modifier parameter: 1 + shift(1) + alt(2) + ctrl(4).
|
||||||
const CSI = { up: 'A', down: 'B', right: 'C', left: 'D', home: 'H', end: 'F' };
|
const CSI = { up: 'A', down: 'B', right: 'C', left: 'D', home: 'H', end: 'F' };
|
||||||
const TILDE = { pgup: 5, pgdn: 6 };
|
const TILDE = { pgup: 5, pgdn: 6 };
|
||||||
function keySeq(k) {
|
function keySeq(k, term) {
|
||||||
const m = useMods(), p = 1 + (m.alt ? 2 : 0) + (m.ctrl ? 4 : 0);
|
const m = useMods(), p = 1 + (m.alt ? 2 : 0) + (m.ctrl ? 4 : 0);
|
||||||
if (CSI[k]) {
|
if (CSI[k]) {
|
||||||
if (p > 1) return '\x1b[1;' + p + CSI[k];
|
if (p > 1) return '\x1b[1;' + p + CSI[k];
|
||||||
@@ -286,27 +289,42 @@
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
send(keySeq(b.dataset.k));
|
const s = cur();
|
||||||
|
if (s) s.send(keySeq(b.dataset.k, s.term));
|
||||||
});
|
});
|
||||||
b.addEventListener('pointercancel', function () { if (press) { clearTimeout(press.timer); press = null; } });
|
b.addEventListener('pointercancel', function () { if (press) { clearTimeout(press.timer); press = null; } });
|
||||||
b.addEventListener('contextmenu', function (e) { e.preventDefault(); });
|
b.addEventListener('contextmenu', function (e) { e.preventDefault(); });
|
||||||
});
|
});
|
||||||
|
|
||||||
function sendResize() { queueIn({ t: 'r', cols: term.cols, rows: term.rows }); }
|
|
||||||
// --- Selecting and copying on touch screens ---
|
// --- Selecting and copying on touch screens ---
|
||||||
// xterm.js has no touch selection of its own. A long press selects the word
|
// xterm.js has no touch selection of its own. A long press selects the word
|
||||||
// under the finger and dragging on from there stretches the selection; a
|
// under the finger and dragging on from there stretches the selection; a
|
||||||
// menu then copies it or pastes. A plain tap or swipe still types and scrolls.
|
// menu then copies it or pastes. A plain tap or swipe still types and scrolls.
|
||||||
|
const menu = document.getElementById('selmenu');
|
||||||
|
let selecting = false;
|
||||||
|
function showMenu() {
|
||||||
|
const s = cur();
|
||||||
|
const keys = document.getElementById('keys').getBoundingClientRect();
|
||||||
|
menu.style.top = '';
|
||||||
|
menu.style.bottom = (window.innerHeight - keys.top + 14) + 'px';
|
||||||
|
menu.querySelector('[data-a="copy"]').disabled = !(s && s.term.hasSelection());
|
||||||
|
menu.hidden = false;
|
||||||
|
}
|
||||||
|
function selectionChanged(s) {
|
||||||
|
if (!touch || s !== cur()) return;
|
||||||
|
if (s.term.hasSelection()) showMenu();
|
||||||
|
else if (!selecting) menu.hidden = true;
|
||||||
|
}
|
||||||
if (touch) {
|
if (touch) {
|
||||||
const box = document.getElementById('terminal'), menu = document.getElementById('selmenu');
|
const box = document.getElementById('area');
|
||||||
let timer = null, start = null, anchor = null, selecting = false;
|
let timer = null, start = null, anchor = null;
|
||||||
function cellAt(x, y) {
|
function cellAt(term, el, x, y) {
|
||||||
const r = box.querySelector('.xterm-screen').getBoundingClientRect();
|
const r = el.querySelector('.xterm-screen').getBoundingClientRect();
|
||||||
const col = Math.max(0, Math.min(term.cols - 1, Math.floor((x - r.left) / (r.width / term.cols))));
|
const col = Math.max(0, Math.min(term.cols - 1, Math.floor((x - r.left) / (r.width / term.cols))));
|
||||||
const row = Math.max(0, Math.min(term.rows - 1, Math.floor((y - r.top) / (r.height / term.rows))));
|
const row = Math.max(0, Math.min(term.rows - 1, Math.floor((y - r.top) / (r.height / term.rows))));
|
||||||
return { col: col, row: row + term.buffer.active.viewportY };
|
return { col: col, row: row + term.buffer.active.viewportY };
|
||||||
}
|
}
|
||||||
function selectWord(c) {
|
function selectWord(term, c) {
|
||||||
const line = term.buffer.active.getLine(c.row);
|
const line = term.buffer.active.getLine(c.row);
|
||||||
const text = line ? line.translateToString(false) : '';
|
const text = line ? line.translateToString(false) : '';
|
||||||
const word = ch => ch && !/\s/.test(ch);
|
const word = ch => ch && !/\s/.test(ch);
|
||||||
@@ -318,37 +336,33 @@
|
|||||||
term.select(a, c.row, b - a + 1);
|
term.select(a, c.row, b - a + 1);
|
||||||
anchor = { from: c.row * term.cols + a, to: c.row * term.cols + b };
|
anchor = { from: c.row * term.cols + a, to: c.row * term.cols + b };
|
||||||
}
|
}
|
||||||
function stretch(c) {
|
function stretch(term, c) {
|
||||||
const p = c.row * term.cols + c.col;
|
const p = c.row * term.cols + c.col;
|
||||||
const from = Math.min(anchor.from, p), to = Math.max(anchor.to, p);
|
const from = Math.min(anchor.from, p), to = Math.max(anchor.to, p);
|
||||||
term.select(from % term.cols, Math.floor(from / term.cols), to - from + 1);
|
term.select(from % term.cols, Math.floor(from / term.cols), to - from + 1);
|
||||||
}
|
}
|
||||||
function showMenu() {
|
function hideMenu() { menu.hidden = true; const s = cur(); if (s) s.term.clearSelection(); anchor = null; }
|
||||||
const keys = document.getElementById('keys').getBoundingClientRect();
|
|
||||||
menu.style.top = '';
|
|
||||||
menu.style.bottom = (window.innerHeight - keys.top + 8) + 'px';
|
|
||||||
menu.querySelector('[data-a="copy"]').disabled = !term.hasSelection();
|
|
||||||
menu.hidden = false;
|
|
||||||
}
|
|
||||||
function hideMenu() { menu.hidden = true; term.clearSelection(); anchor = null; }
|
|
||||||
box.addEventListener('touchstart', function (e) {
|
box.addEventListener('touchstart', function (e) {
|
||||||
if (e.touches.length !== 1) { clearTimeout(timer); return; }
|
if (e.touches.length !== 1 || !e.target.closest('.pane-body')) { clearTimeout(timer); return; }
|
||||||
const t = e.touches[0];
|
const t = e.touches[0];
|
||||||
start = { x: t.clientX, y: t.clientY };
|
start = { x: t.clientX, y: t.clientY };
|
||||||
if (!menu.hidden) hideMenu();
|
if (!menu.hidden) hideMenu();
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
timer = setTimeout(function () {
|
timer = setTimeout(function () {
|
||||||
|
const s = cur();
|
||||||
|
if (!s) return;
|
||||||
selecting = true;
|
selecting = true;
|
||||||
selectWord(cellAt(start.x, start.y));
|
selectWord(s.term, cellAt(s.term, s.el, start.x, start.y));
|
||||||
|
showMenu();
|
||||||
if (navigator.vibrate) navigator.vibrate(15);
|
if (navigator.vibrate) navigator.vibrate(15);
|
||||||
}, 450);
|
}, 450);
|
||||||
}, { passive: true, capture: true });
|
}, { passive: true, capture: true });
|
||||||
box.addEventListener('touchmove', function (e) {
|
box.addEventListener('touchmove', function (e) {
|
||||||
const t = e.touches[0];
|
const t = e.touches[0], s = cur();
|
||||||
if (selecting) {
|
if (selecting && s) {
|
||||||
e.preventDefault(); // no scrolling while the selection stretches
|
e.preventDefault(); // no scrolling while the selection stretches
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
stretch(cellAt(t.clientX, t.clientY));
|
stretch(s.term, cellAt(s.term, s.el, t.clientX, t.clientY));
|
||||||
} else if (start && Math.hypot(t.clientX - start.x, t.clientY - start.y) > 8) {
|
} else if (start && Math.hypot(t.clientX - start.x, t.clientY - start.y) > 8) {
|
||||||
clearTimeout(timer); // a swipe: let it scroll
|
clearTimeout(timer); // a swipe: let it scroll
|
||||||
}
|
}
|
||||||
@@ -363,6 +377,11 @@
|
|||||||
}, { passive: false, capture: true });
|
}, { passive: false, capture: true });
|
||||||
box.addEventListener('contextmenu', function (e) { e.preventDefault(); });
|
box.addEventListener('contextmenu', function (e) { e.preventDefault(); });
|
||||||
|
|
||||||
|
const statusEl = document.getElementById('status');
|
||||||
|
function flash(t, cls) {
|
||||||
|
statusEl.textContent = t; statusEl.className = cls;
|
||||||
|
setTimeout(function () { const s = cur(); if (s) s.setState(s.state, s.text); }, 1500);
|
||||||
|
}
|
||||||
function copyText(text) {
|
function copyText(text) {
|
||||||
if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(text);
|
if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(text);
|
||||||
// Plain http: the old way, through a field that is there for a moment.
|
// Plain http: the old way, through a field that is there for a moment.
|
||||||
@@ -377,52 +396,28 @@
|
|||||||
}
|
}
|
||||||
menu.addEventListener('pointerdown', function (e) { e.preventDefault(); });
|
menu.addEventListener('pointerdown', function (e) { e.preventDefault(); });
|
||||||
menu.addEventListener('click', function (e) {
|
menu.addEventListener('click', function (e) {
|
||||||
const b = e.target.closest('button');
|
const b = e.target.closest('button'), s = cur();
|
||||||
if (!b) return;
|
if (!b || !s) return;
|
||||||
const a = b.dataset.a;
|
const a = b.dataset.a;
|
||||||
if (a === 'copy') {
|
if (a === 'copy') {
|
||||||
const text = term.getSelection();
|
const text = s.term.getSelection();
|
||||||
hideMenu();
|
hideMenu();
|
||||||
copyText(text).then(function () { setStatus('скопировано', 'st-ok'); setTimeout(function () { setStatus(connected ? 'подключено' : '', connected ? 'st-ok' : ''); }, 1500); },
|
copyText(text).then(function () { flash('скопировано', 'st-ok'); }, function (err) { flash('не скопировалось: ' + err.message, 'st-err'); });
|
||||||
function (err) { setStatus('не скопировалось: ' + err.message, 'st-err'); });
|
|
||||||
} else if (a === 'paste') {
|
} else if (a === 'paste') {
|
||||||
hideMenu();
|
hideMenu();
|
||||||
if (!navigator.clipboard || !navigator.clipboard.readText) { setStatus('вставка: браузер не даёт буфер', 'st-err'); return; }
|
if (!navigator.clipboard || !navigator.clipboard.readText) { flash('вставка: браузер не даёт буфер', 'st-err'); return; }
|
||||||
navigator.clipboard.readText().then(function (t) { if (t) term.paste(t); }, function () { setStatus('вставка: нет доступа к буферу', 'st-err'); });
|
navigator.clipboard.readText().then(function (t) { if (t) s.term.paste(t); }, function () { flash('вставка: нет доступа к буферу', 'st-err'); });
|
||||||
} else if (a === 'all') {
|
} else if (a === 'all') {
|
||||||
term.selectAll();
|
s.term.selectAll();
|
||||||
showMenu();
|
showMenu();
|
||||||
} else {
|
} else {
|
||||||
hideMenu();
|
hideMenu();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
return { withMods: withMods, selectionChanged: selectionChanged };
|
||||||
function refit() {
|
|
||||||
if (touch && window.visualViewport) {
|
|
||||||
// Follow the visible area, so the extra keys sit right above the
|
|
||||||
// on-screen keyboard.
|
|
||||||
const vv = window.visualViewport;
|
|
||||||
document.body.style.top = vv.offsetTop + 'px';
|
|
||||||
document.body.style.height = vv.height + 'px';
|
|
||||||
}
|
|
||||||
fit.fit();
|
|
||||||
}
|
|
||||||
window.addEventListener('resize', refit);
|
|
||||||
if (touch && window.visualViewport) {
|
|
||||||
window.visualViewport.addEventListener('resize', refit);
|
|
||||||
window.visualViewport.addEventListener('scroll', refit);
|
|
||||||
}
|
|
||||||
refit();
|
|
||||||
term.onResize(sendResize);
|
|
||||||
|
|
||||||
window.addEventListener('pagehide', function () {
|
|
||||||
if (sid) navigator.sendBeacon(base + sid + '/close');
|
|
||||||
});
|
|
||||||
|
|
||||||
term.focus();
|
|
||||||
open();
|
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
<script src="{{asset "terminal.js"}}"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/coder/websocket"
|
||||||
|
"github.com/coder/websocket/wsjson"
|
||||||
|
|
||||||
|
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// An agent's WebSocket carries what its long polls would: control messages
|
||||||
|
// pushed the moment they are queued, and the input and output of its
|
||||||
|
// terminal and http-forward sessions, without a request per keystroke. Tasks,
|
||||||
|
// file transfers and the speed test stay on plain requests.
|
||||||
|
|
||||||
|
// wsPing keeps the connection alive through proxies that drop quiet ones
|
||||||
|
// (Cloudflare after 100 s).
|
||||||
|
const wsPing = 20 * time.Second
|
||||||
|
|
||||||
|
// wsAgents are the agents connected by WebSocket now, for the panel to show.
|
||||||
|
type wsAgents struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
m map[string]*websocket.Conn
|
||||||
|
}
|
||||||
|
|
||||||
|
// set records the agent's connection, closing one it had before.
|
||||||
|
func (c *wsAgents) set(id string, conn *websocket.Conn) {
|
||||||
|
c.mu.Lock()
|
||||||
|
if c.m == nil {
|
||||||
|
c.m = map[string]*websocket.Conn{}
|
||||||
|
}
|
||||||
|
old := c.m[id]
|
||||||
|
c.m[id] = conn
|
||||||
|
c.mu.Unlock()
|
||||||
|
if old != nil {
|
||||||
|
old.Close(websocket.StatusGoingAway, "replaced")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *wsAgents) drop(id string, conn *websocket.Conn) {
|
||||||
|
c.mu.Lock()
|
||||||
|
if c.m[id] == conn {
|
||||||
|
delete(c.m, id)
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// On reports whether the agent is on its WebSocket now.
|
||||||
|
func (c *wsAgents) On(id string) bool {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
return c.m[id] != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// transportOf is the agent's setting as stored, "auto" when unset.
|
||||||
|
func (s *Server) transportOf(agentID string) string {
|
||||||
|
if a, err := s.db.GetAgent(agentID); err == nil && a != nil && a.Transport != "" {
|
||||||
|
return a.Transport
|
||||||
|
}
|
||||||
|
return proto.TransportAuto
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/agent/ws - the agent's WebSocket. Refused (409) when the agent is
|
||||||
|
// set to long polling.
|
||||||
|
func (s *Server) handleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||||
|
a := s.authenticateAgent(r)
|
||||||
|
if a == nil {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if a.Transport == proto.TransportLP {
|
||||||
|
w.Header().Set(proto.TransportHeader, proto.TransportLP)
|
||||||
|
http.Error(w, "long polling only", http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if v := r.Header.Get("X-Agent-Version"); v != "" {
|
||||||
|
_ = s.db.TouchAgentInfo(a.ID, v, firstNonEmpty(r.Header.Get("X-Agent-User"), a.RunUser))
|
||||||
|
}
|
||||||
|
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{CompressionMode: websocket.CompressionDisabled})
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.viaDirectA.Store(a.ID, s.viaDirect(r))
|
||||||
|
conn.SetReadLimit(4 << 20)
|
||||||
|
ctx, cancel := context.WithCancel(r.Context())
|
||||||
|
defer cancel()
|
||||||
|
s.wsAgents.set(a.ID, conn)
|
||||||
|
defer s.wsAgents.drop(a.ID, conn)
|
||||||
|
defer conn.CloseNow()
|
||||||
|
|
||||||
|
send := func(m proto.WSMsg) error {
|
||||||
|
wctx, wcancel := context.WithTimeout(ctx, 30*time.Second)
|
||||||
|
defer wcancel()
|
||||||
|
return wsjson.Write(wctx, conn, m)
|
||||||
|
}
|
||||||
|
cfg := func() proto.WSMsg {
|
||||||
|
return proto.WSMsg{T: "cfg", Tunnel: s.tunnelAdvert(), Transport: s.transportOf(a.ID), Fallback: s.db.DirectAddress(), Main: s.db.HostAddress()}
|
||||||
|
}
|
||||||
|
if send(cfg()) != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Control: what a poll would get, as soon as it is queued. An idle round
|
||||||
|
// sends the settings, which doubles as a sign of life.
|
||||||
|
go func() {
|
||||||
|
defer cancel()
|
||||||
|
for ctx.Err() == nil {
|
||||||
|
msg := s.hub.WaitControl(ctx.Done(), a.ID, pollHold)
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = s.db.TouchAgent(a.ID)
|
||||||
|
out := proto.WSMsg{T: "ctrl", Ctrl: &msg}
|
||||||
|
if msg.Type == proto.CtrlIdle {
|
||||||
|
out = cfg()
|
||||||
|
}
|
||||||
|
if send(out) != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if out.Transport == proto.TransportLP {
|
||||||
|
conn.Close(websocket.StatusNormalClosure, "long polling only")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
t := time.NewTicker(wsPing)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
pctx, pcancel := context.WithTimeout(ctx, 15*time.Second)
|
||||||
|
err := conn.Ping(pctx)
|
||||||
|
pcancel()
|
||||||
|
if err != nil {
|
||||||
|
cancel()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
subs := map[string]bool{}
|
||||||
|
for {
|
||||||
|
var m proto.WSMsg
|
||||||
|
if err := wsjson.Read(ctx, conn, &m); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch m.T {
|
||||||
|
case "sub":
|
||||||
|
if subs[m.SID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sess := s.hub.Session(m.SID)
|
||||||
|
if sess == nil || sess.AgentID != a.ID {
|
||||||
|
_ = send(proto.WSMsg{T: "gone", SID: m.SID})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
subs[m.SID] = true
|
||||||
|
go s.pumpSession(ctx, sess, send)
|
||||||
|
case "out":
|
||||||
|
sess := s.hub.Session(m.SID)
|
||||||
|
if sess == nil || sess.AgentID != a.ID || m.Out == nil {
|
||||||
|
_ = send(proto.WSMsg{T: "gone", SID: m.SID})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !sess.PushOut(*m.Out) {
|
||||||
|
_ = send(proto.WSMsg{T: "gone", SID: m.SID})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if m.Out.Exit {
|
||||||
|
sess.close()
|
||||||
|
time.AfterFunc(pollHold, func() { s.hub.CloseSession(sess.ID) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pumpSession sends a session's input down the WebSocket as it comes. Input
|
||||||
|
// taken just as the connection ends goes back to the session, for the agent's
|
||||||
|
// long poll to collect.
|
||||||
|
func (s *Server) pumpSession(ctx context.Context, sess *Session, send func(proto.WSMsg) error) {
|
||||||
|
for ctx.Err() == nil {
|
||||||
|
frames, alive := sess.PollIn(pollHold)
|
||||||
|
if !alive {
|
||||||
|
_ = send(proto.WSMsg{T: "gone", SID: sess.ID})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(frames) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ctx.Err() != nil || send(proto.WSMsg{T: "in", SID: sess.ID, Frames: frames}) != nil {
|
||||||
|
sess.Unpoll(frames)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user