Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fa3de757c | ||
|
|
0279c1d887 | ||
|
|
0ccac0bc82 | ||
|
|
e5a45b872a | ||
|
|
9e684605ef | ||
|
|
13897c7478 | ||
|
|
4d2cfe8244 | ||
|
|
25ffa60d09 | ||
|
|
baee90b564 | ||
|
|
79828e3f8a | ||
|
|
021da93229 | ||
|
|
36dc12b69d | ||
|
|
1297ab36d0 | ||
|
|
fd382d6313 | ||
|
|
fc488717d3 | ||
|
|
dbb46173a3 | ||
|
|
b47e824692 | ||
|
|
5eca9ca215 | ||
|
|
467bd3cb4f | ||
|
|
18d6684c55 | ||
|
|
1a38c2ac18 | ||
|
|
5a4dc596d5 | ||
|
|
5a204ae9bb | ||
|
|
119b524825 | ||
|
|
29353e5e6a | ||
|
|
47f1b2e55b | ||
|
|
73bb720ec6 | ||
|
|
6dd1120a41 | ||
|
|
67222e2e05 | ||
|
|
e7b5d28470 | ||
|
|
d2d6fe0100 | ||
|
|
e983fc179a | ||
|
|
89d5bfd3ab | ||
|
|
55c37a0097 | ||
|
|
cefdae267b | ||
|
|
44e7137d86 | ||
|
|
10ffa393ca | ||
|
|
114c6fc530 | ||
|
|
0aa066fd01 | ||
|
|
21436d5c73 | ||
|
|
65359f5497 | ||
|
|
8eb2bbd35d | ||
|
|
55db8f03b3 | ||
|
|
e9a845a6ea | ||
|
|
23e382bf7f | ||
|
|
438b20dae0 | ||
|
|
19f8b5291e | ||
|
|
c911143eb2 | ||
|
|
1a90af7800 | ||
|
|
92c9616826 | ||
|
|
2db44e53f7 | ||
|
|
68ede6ab25 | ||
|
|
d448c1e3a4 | ||
|
|
3d6b04e78a | ||
|
|
e1f5595afc | ||
|
|
3e46116a9b | ||
|
|
10b8995041 | ||
|
|
225b7ecfcc |
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.6
|
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:
|
||||||
|
# - Upgrade / Connection headers -> let the agents' WebSocket through
|
||||||
# - proxy_buffering off -> deliver long-poll responses immediately
|
# - proxy_buffering off -> deliver long-poll responses immediately
|
||||||
# - proxy_read_timeout 120s -> longer than the 25s long-poll hold
|
# - proxy_read_timeout 300s -> longer than the 25s long-poll hold
|
||||||
#
|
#
|
||||||
# NOTE: proxy_pass MUST start with "http://". A bare "proxy_pass 127.0.0.1:8091;"
|
# NOTE: proxy_pass MUST start with "http://". A bare "proxy_pass 127.0.0.1:8091;"
|
||||||
# causes nginx error: [emerg] invalid URL prefix.
|
# causes nginx error: [emerg] invalid URL prefix.
|
||||||
|
|
||||||
|
# "Connection: upgrade" for a WebSocket, "close" for everything else.
|
||||||
|
map $http_upgrade $connection_upgrade {
|
||||||
|
default upgrade;
|
||||||
|
'' close;
|
||||||
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 8445 ssl;
|
listen 8445 ssl;
|
||||||
server_name REDACTED;
|
server_name REDACTED;
|
||||||
@@ -21,7 +28,8 @@ server {
|
|||||||
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
|
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
|
||||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
|
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
|
||||||
|
|
||||||
# Agent binaries can be large; allow the download.
|
# Files go through the panel in 4 MB pieces; 64m leaves room for a client
|
||||||
|
# that predates the pieces, which takes a file of up to 32 MB in one request.
|
||||||
client_max_body_size 64m;
|
client_max_body_size 64m;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
@@ -32,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;
|
||||||
@@ -39,7 +50,7 @@ server {
|
|||||||
# Pass uploads on as they arrive: the speed test through the browser
|
# Pass uploads on as they arrive: the speed test through the browser
|
||||||
# times them on the client.
|
# times them on the client.
|
||||||
proxy_request_buffering off;
|
proxy_request_buffering off;
|
||||||
proxy_read_timeout 120s;
|
proxy_read_timeout 300s;
|
||||||
proxy_send_timeout 120s;
|
proxy_send_timeout 300s;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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=
|
||||||
+172
-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,16 +44,23 @@ 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.
|
||||||
type Agent struct {
|
type Agent struct {
|
||||||
cfg Config
|
cfg Config
|
||||||
http *http.Client
|
http *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
|
||||||
@@ -81,6 +90,8 @@ func New(cfg Config) *Agent {
|
|||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
// Timeout must exceed the server's long-poll hold (25s) plus slack.
|
// Timeout must exceed the server's long-poll hold (25s) plus slack.
|
||||||
http: &http.Client{Timeout: 60 * time.Second, Transport: tr},
|
http: &http.Client{Timeout: 60 * time.Second, Transport: tr},
|
||||||
|
// File streams take as long as the file needs.
|
||||||
|
stream: &http.Client{Transport: tr},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,11 +102,20 @@ func (a *Agent) Run(ctx context.Context) error {
|
|||||||
return err
|
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
|
||||||
@@ -104,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{
|
||||||
@@ -133,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)
|
||||||
@@ -154,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.
|
||||||
@@ -180,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)
|
||||||
@@ -188,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
|
||||||
@@ -197,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
|
||||||
}
|
}
|
||||||
@@ -219,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
|
||||||
}
|
}
|
||||||
@@ -274,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
|
||||||
|
}
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Files go to and from the browser in pieces, each a request of its own
|
||||||
|
// through the server: small enough for any proxy in front, and sent again
|
||||||
|
// alone when the link drops it. A few go at once, so a good link is kept as
|
||||||
|
// busy as by one long stream.
|
||||||
|
|
||||||
|
const (
|
||||||
|
// xferWorkers is how many pieces are in flight at once.
|
||||||
|
xferWorkers = 4
|
||||||
|
// xferPieceTimeout bounds one piece's request: a link that slow, or
|
||||||
|
// stuck, gets the piece sent again.
|
||||||
|
xferPieceTimeout = 5 * time.Minute
|
||||||
|
// xferTries bounds how often a piece is tried over a failing link.
|
||||||
|
xferTries = 30
|
||||||
|
)
|
||||||
|
|
||||||
|
// errXferGone is the server saying the transfer is over: the browser left
|
||||||
|
// or gave up.
|
||||||
|
type errXferGone string
|
||||||
|
|
||||||
|
func (e errXferGone) Error() string { return string(e) }
|
||||||
|
|
||||||
|
func (a *Agent) xferURL(id string, i int) string {
|
||||||
|
return a.host() + "/api/agent/fs/xfer/" + id + "/" + strconv.Itoa(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
// xferRequest makes one request for a piece, trying again over a failing link.
|
||||||
|
// A 503 is the server waiting on the other side: asked again at once.
|
||||||
|
func (a *Agent) xferRequest(ctx context.Context, newReq func(context.Context) (*http.Request, error), keep func(*http.Response) error) error {
|
||||||
|
fails := 0
|
||||||
|
for {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rctx, cancel := context.WithTimeout(ctx, xferPieceTimeout)
|
||||||
|
req, err := newReq(rctx)
|
||||||
|
if err != nil {
|
||||||
|
cancel()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.authHeaders(req)
|
||||||
|
resp, err := a.stream.Do(req)
|
||||||
|
if err == nil {
|
||||||
|
switch {
|
||||||
|
case resp.StatusCode == http.StatusGone:
|
||||||
|
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||||
|
resp.Body.Close()
|
||||||
|
cancel()
|
||||||
|
return errXferGone(strings.TrimSpace(string(msg)))
|
||||||
|
case resp.StatusCode == http.StatusServiceUnavailable:
|
||||||
|
io.Copy(io.Discard, resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
cancel()
|
||||||
|
continue
|
||||||
|
case resp.StatusCode < 300:
|
||||||
|
err = keep(resp)
|
||||||
|
resp.Body.Close()
|
||||||
|
cancel()
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
io.Copy(io.Discard, resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
err = errStatus(resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
if fails++; fails >= xferTries {
|
||||||
|
return fmt.Errorf("сеть не держит передачу: %w", err)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-time.After(min(time.Duration(fails)*time.Second, 10*time.Second)):
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pieces runs fn for every piece, xferWorkers at a time, until one fails.
|
||||||
|
func pieces(ctx context.Context, n int, fn func(ctx context.Context, i int) error) error {
|
||||||
|
ctx, cancel := context.WithCancel(ctx)
|
||||||
|
defer cancel()
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
next int
|
||||||
|
first error
|
||||||
|
wg sync.WaitGroup
|
||||||
|
)
|
||||||
|
for w := 0; w < min(xferWorkers, n); w++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for {
|
||||||
|
mu.Lock()
|
||||||
|
if first != nil || next >= n {
|
||||||
|
mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
i := next
|
||||||
|
next++
|
||||||
|
mu.Unlock()
|
||||||
|
if err := fn(ctx, i); err != nil {
|
||||||
|
mu.Lock()
|
||||||
|
if first == nil {
|
||||||
|
first = err
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
return first
|
||||||
|
}
|
||||||
|
|
||||||
|
func pieceCount(size int64, chunk int) int {
|
||||||
|
if size <= 0 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return int((size + int64(chunk) - 1) / int64(chunk))
|
||||||
|
}
|
||||||
|
|
||||||
|
// doFSPut writes the file the browser is uploading. It lands in a temporary
|
||||||
|
// file next to the target first, so a broken upload leaves the old file as it
|
||||||
|
// was.
|
||||||
|
func (a *Agent) doFSPut(ctx context.Context, env proto.TaskEnvelope) proto.TaskResult {
|
||||||
|
var args proto.FSXferArgs
|
||||||
|
if err := json.Unmarshal(env.Args, &args); err != nil {
|
||||||
|
return proto.TaskResult{Error: err.Error()}
|
||||||
|
}
|
||||||
|
if args.Size < 0 || args.Size > proto.FSFileCap || args.Chunk <= 0 {
|
||||||
|
return proto.TaskResult{Error: "файл больше 512 МБ"}
|
||||||
|
}
|
||||||
|
dir, base := filepath.Split(args.Path)
|
||||||
|
if dir == "" {
|
||||||
|
dir = "."
|
||||||
|
}
|
||||||
|
if info, err := os.Stat(args.Path); err == nil && info.IsDir() {
|
||||||
|
return proto.TaskResult{Error: "это каталог"}
|
||||||
|
}
|
||||||
|
tmp, err := os.CreateTemp(dir, "."+base+".lpra-*")
|
||||||
|
if err != nil {
|
||||||
|
return proto.TaskResult{Error: err.Error()}
|
||||||
|
}
|
||||||
|
done := false
|
||||||
|
defer func() {
|
||||||
|
if !done {
|
||||||
|
tmp.Close()
|
||||||
|
os.Remove(tmp.Name())
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Pieces written so far, to tell the server which it can let go.
|
||||||
|
n := pieceCount(args.Size, args.Chunk)
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
got = make([]bool, n)
|
||||||
|
contig int
|
||||||
|
)
|
||||||
|
err = pieces(ctx, n, func(ctx context.Context, i int) error {
|
||||||
|
want := min(int64(args.Chunk), args.Size-int64(i)*int64(args.Chunk))
|
||||||
|
mu.Lock()
|
||||||
|
ack := contig
|
||||||
|
mu.Unlock()
|
||||||
|
var data []byte
|
||||||
|
err := a.xferRequest(ctx, func(ctx context.Context) (*http.Request, error) {
|
||||||
|
return http.NewRequestWithContext(ctx, http.MethodGet, a.xferURL(args.Xfer, i)+"?ack="+strconv.Itoa(ack), nil)
|
||||||
|
}, func(resp *http.Response) error {
|
||||||
|
b, err := io.ReadAll(io.LimitReader(resp.Body, int64(args.Chunk)+1))
|
||||||
|
if err == nil && int64(len(b)) != want {
|
||||||
|
err = errors.New("кусок не той длины")
|
||||||
|
}
|
||||||
|
data = b
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tmp.WriteAt(data, int64(i)*int64(args.Chunk)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
got[i] = true
|
||||||
|
for contig < n && got[contig] {
|
||||||
|
contig++
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
var gone errXferGone
|
||||||
|
if errors.As(err, &gone) {
|
||||||
|
return proto.TaskResult{Error: "загрузка прервана: " + string(gone)}
|
||||||
|
}
|
||||||
|
return proto.TaskResult{Error: "загрузка прервана: " + err.Error()}
|
||||||
|
}
|
||||||
|
if err := tmp.Truncate(args.Size); err != nil {
|
||||||
|
return proto.TaskResult{Error: err.Error()}
|
||||||
|
}
|
||||||
|
if err := tmp.Close(); err != nil {
|
||||||
|
return proto.TaskResult{Error: err.Error()}
|
||||||
|
}
|
||||||
|
// A file that is being replaced keeps its mode and owner.
|
||||||
|
mode := os.FileMode(0o644)
|
||||||
|
if info, err := os.Stat(args.Path); err == nil {
|
||||||
|
mode = info.Mode().Perm()
|
||||||
|
if st, ok := info.Sys().(*syscall.Stat_t); ok {
|
||||||
|
_ = os.Chown(tmp.Name(), int(st.Uid), int(st.Gid))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := os.Chmod(tmp.Name(), mode); err != nil {
|
||||||
|
return proto.TaskResult{Error: err.Error()}
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmp.Name(), args.Path); err != nil {
|
||||||
|
return proto.TaskResult{Error: err.Error()}
|
||||||
|
}
|
||||||
|
done = true
|
||||||
|
data, _ := json.Marshal(map[string]any{"path": args.Path, "size": args.Size})
|
||||||
|
return proto.TaskResult{OK: true, Data: data}
|
||||||
|
}
|
||||||
|
|
||||||
|
// doFSSend sends a file to the browser that asked for it.
|
||||||
|
func (a *Agent) doFSSend(ctx context.Context, env proto.TaskEnvelope) proto.TaskResult {
|
||||||
|
var args proto.FSXferArgs
|
||||||
|
if err := json.Unmarshal(env.Args, &args); err != nil {
|
||||||
|
return proto.TaskResult{Error: err.Error()}
|
||||||
|
}
|
||||||
|
if args.Chunk <= 0 {
|
||||||
|
return proto.TaskResult{Error: "не задан размер куска"}
|
||||||
|
}
|
||||||
|
f, err := os.Open(args.Path)
|
||||||
|
if err != nil {
|
||||||
|
return proto.TaskResult{Error: err.Error()}
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
info, err := f.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return proto.TaskResult{Error: err.Error()}
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
return proto.TaskResult{Error: "это каталог"}
|
||||||
|
}
|
||||||
|
size := info.Size()
|
||||||
|
if size > proto.FSFileCap {
|
||||||
|
return proto.TaskResult{Error: "файл больше 512 МБ"}
|
||||||
|
}
|
||||||
|
sizeHdr := strconv.FormatInt(size, 10)
|
||||||
|
err = pieces(ctx, pieceCount(size, args.Chunk), func(ctx context.Context, i int) error {
|
||||||
|
off := int64(i) * int64(args.Chunk)
|
||||||
|
buf := make([]byte, min(int64(args.Chunk), size-off))
|
||||||
|
if _, err := f.ReadAt(buf, off); err != nil && err != io.EOF {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return a.xferRequest(ctx, func(ctx context.Context) (*http.Request, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.xferURL(args.Xfer, i), bytes.NewReader(buf))
|
||||||
|
if err == nil {
|
||||||
|
req.Header.Set("X-Size", sizeHdr)
|
||||||
|
req.Header.Set("Content-Type", "application/octet-stream")
|
||||||
|
}
|
||||||
|
return req, err
|
||||||
|
}, func(*http.Response) error { return nil })
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return proto.TaskResult{Error: "передача прервана: " + err.Error()}
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(map[string]any{"path": args.Path, "size": size})
|
||||||
|
return proto.TaskResult{OK: true, Data: data}
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The server can be reached at two addresses: the main one (behind
|
||||||
|
// Cloudflare, say) and a direct one that goes to the server itself. The
|
||||||
|
// direct one comes from the installer or, for agents installed before it was
|
||||||
|
// set, from the server, and is kept in the state file. When the address in
|
||||||
|
// use has been out of reach for a while the agent tries the other; on the
|
||||||
|
// direct one it keeps checking the main one and goes back when it answers.
|
||||||
|
|
||||||
|
const (
|
||||||
|
// switchAfter is how long the address in use may fail before the other
|
||||||
|
// is tried.
|
||||||
|
switchAfter = 30 * time.Second
|
||||||
|
// mainCheck is how often the main address is checked from the direct one.
|
||||||
|
mainCheck = 5 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
type hostState struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
cur string
|
||||||
|
downSince time.Time
|
||||||
|
changed bool // the address changed: the WebSocket goes there at once
|
||||||
|
}
|
||||||
|
|
||||||
|
// host is the server address in use now.
|
||||||
|
func (a *Agent) host() string {
|
||||||
|
a.hosts.mu.Lock()
|
||||||
|
defer a.hosts.mu.Unlock()
|
||||||
|
if a.hosts.cur == "" {
|
||||||
|
return a.cfg.Host
|
||||||
|
}
|
||||||
|
return a.hosts.cur
|
||||||
|
}
|
||||||
|
|
||||||
|
// fallback is the direct address: the installer's, else the one the server
|
||||||
|
// told.
|
||||||
|
func (a *Agent) fallback() string {
|
||||||
|
if a.cfg.Fallback != "" {
|
||||||
|
return a.cfg.Fallback
|
||||||
|
}
|
||||||
|
a.stateMu.Lock()
|
||||||
|
defer a.stateMu.Unlock()
|
||||||
|
return a.state.Fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
// learnAddrs keeps the server's other address from the two it tells: the
|
||||||
|
// direct one, or the main one when the agent was set to the direct one.
|
||||||
|
func (a *Agent) learnAddrs(main, direct string) {
|
||||||
|
main = strings.TrimRight(strings.TrimSpace(main), "/")
|
||||||
|
direct = strings.TrimRight(strings.TrimSpace(direct), "/")
|
||||||
|
v := direct
|
||||||
|
if direct != "" && strings.EqualFold(direct, a.cfg.Host) {
|
||||||
|
v = main
|
||||||
|
}
|
||||||
|
if v == "" || strings.EqualFold(v, a.cfg.Host) || a.cfg.Fallback != "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.stateMu.Lock()
|
||||||
|
same := a.state.Fallback == v
|
||||||
|
if !same {
|
||||||
|
a.state.Fallback = v
|
||||||
|
}
|
||||||
|
a.stateMu.Unlock()
|
||||||
|
if !same {
|
||||||
|
log.Printf("fallback address: %s", v)
|
||||||
|
_ = a.saveState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// setHost moves to addr: requests go there from now on, and the WebSocket
|
||||||
|
// is dropped to reconnect there.
|
||||||
|
func (a *Agent) setHost(addr string) {
|
||||||
|
a.hosts.mu.Lock()
|
||||||
|
if a.hosts.cur == addr || (a.hosts.cur == "" && addr == a.cfg.Host) {
|
||||||
|
a.hosts.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.hosts.cur, a.hosts.downSince, a.hosts.changed = addr, time.Time{}, true
|
||||||
|
a.hosts.mu.Unlock()
|
||||||
|
log.Printf("server address: %s", addr)
|
||||||
|
if l := a.link.Load(); l != nil {
|
||||||
|
l.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hostChanged reports, once, that the address changed.
|
||||||
|
func (a *Agent) hostChanged() bool {
|
||||||
|
a.hosts.mu.Lock()
|
||||||
|
defer a.hosts.mu.Unlock()
|
||||||
|
c := a.hosts.changed
|
||||||
|
a.hosts.changed = false
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// reached notes the server answered; failed notes it did not, and after
|
||||||
|
// switchAfter of that moves to the other address.
|
||||||
|
func (a *Agent) reached() {
|
||||||
|
a.hosts.mu.Lock()
|
||||||
|
a.hosts.downSince = time.Time{}
|
||||||
|
a.hosts.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) failed() {
|
||||||
|
fb := a.fallback()
|
||||||
|
if fb == "" || fb == a.cfg.Host {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.hosts.mu.Lock()
|
||||||
|
now := time.Now()
|
||||||
|
if a.hosts.downSince.IsZero() {
|
||||||
|
a.hosts.downSince = now
|
||||||
|
a.hosts.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
long := now.Sub(a.hosts.downSince) >= switchAfter
|
||||||
|
cur := a.hosts.cur
|
||||||
|
a.hosts.mu.Unlock()
|
||||||
|
if !long {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next := fb
|
||||||
|
if cur == fb {
|
||||||
|
next = a.cfg.Host
|
||||||
|
}
|
||||||
|
log.Printf("server out of reach for %s, trying %s", switchAfter, next)
|
||||||
|
a.setHost(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
// watchMain moves back to the main address once it answers again.
|
||||||
|
func (a *Agent) watchMain(ctx context.Context) {
|
||||||
|
t := time.NewTicker(mainCheck)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
}
|
||||||
|
if a.host() == a.cfg.Host {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if a.reachableAt(ctx, a.cfg.Host) {
|
||||||
|
log.Printf("main address answers again")
|
||||||
|
a.setHost(a.cfg.Host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reachableAt reports whether the server answers a quick request at base.
|
||||||
|
func (a *Agent) reachableAt(ctx context.Context, base string) bool {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, base+"/api/agent/config", nil)
|
||||||
|
a.authHeaders(req)
|
||||||
|
resp, err := a.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode < 300 {
|
||||||
|
a.learnAddrs(resp.Header.Get(proto.MainHeader), resp.Header.Get(proto.FallbackHeader))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
+10
-2
@@ -22,6 +22,14 @@ func (a *Agent) runTask(ctx context.Context, taskID string) {
|
|||||||
res = a.doUpdate()
|
res = a.doUpdate()
|
||||||
case proto.TaskSysSpeed:
|
case proto.TaskSysSpeed:
|
||||||
res = a.doSpeed(ctx, env)
|
res = a.doSpeed(ctx, env)
|
||||||
|
case proto.TaskFSPut:
|
||||||
|
res = a.doFSPut(ctx, env)
|
||||||
|
case proto.TaskFSSend:
|
||||||
|
res = a.doFSSend(ctx, env)
|
||||||
|
case proto.TaskFSShipOut:
|
||||||
|
res = a.doShipOut(ctx, env)
|
||||||
|
case proto.TaskFSShipIn:
|
||||||
|
res = a.doShipIn(ctx, env)
|
||||||
default:
|
default:
|
||||||
res = a.execTask(env)
|
res = a.execTask(env)
|
||||||
}
|
}
|
||||||
@@ -30,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 {
|
||||||
@@ -46,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,6 +4,9 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/creack/pty"
|
"github.com/creack/pty"
|
||||||
)
|
)
|
||||||
@@ -12,6 +15,27 @@ import (
|
|||||||
type PTY struct {
|
type PTY struct {
|
||||||
cmd *exec.Cmd
|
cmd *exec.Cmd
|
||||||
f *os.File
|
f *os.File
|
||||||
|
exited chan struct{} // closed once the shell has exited
|
||||||
|
once sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
// hangupGrace is how long a shell gets after the hangup to save its history
|
||||||
|
// and exit before it is killed.
|
||||||
|
const hangupGrace = 3 * time.Second
|
||||||
|
|
||||||
|
// openPTYs are the terminals running now, to close on shutdown.
|
||||||
|
var openPTYs sync.Map // *PTY -> struct{}
|
||||||
|
|
||||||
|
// CloseTerminals hangs up every open terminal and waits for them, so the
|
||||||
|
// shells save their history before the agent exits.
|
||||||
|
func CloseTerminals() {
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
openPTYs.Range(func(k, _ any) bool {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() { defer wg.Done(); k.(*PTY).Close() }()
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
wg.Wait()
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartPTY launches a login shell attached to a new PTY. With user empty (or
|
// StartPTY launches a login shell attached to a new PTY. With user empty (or
|
||||||
@@ -20,8 +44,16 @@ type PTY struct {
|
|||||||
// `su -l`, which only a root agent can do without a password.
|
// `su -l`, which only a root agent can do without a password.
|
||||||
func StartPTY(user string) (*PTY, error) {
|
func StartPTY(user string) (*PTY, error) {
|
||||||
var cmd *exec.Cmd
|
var cmd *exec.Cmd
|
||||||
|
env := append(os.Environ(), "TERM=xterm-256color")
|
||||||
if user == "" || user == currentUser() {
|
if user == "" || user == currentUser() {
|
||||||
cmd = exec.Command(defaultShell(), "-l")
|
cmd = exec.Command(defaultShell(), "-l")
|
||||||
|
// A login shell does not change directory by itself: it would start
|
||||||
|
// where the service runs, "/". Start it at home, and say where home
|
||||||
|
// is, as a root service often has no $HOME.
|
||||||
|
if home := homeDir(""); isDir(home) {
|
||||||
|
cmd.Dir = home
|
||||||
|
env = append(env, "HOME="+home)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
if !validUserName(user) {
|
if !validUserName(user) {
|
||||||
return nil, fmt.Errorf("недопустимое имя пользователя %q", user)
|
return nil, fmt.Errorf("недопустимое имя пользователя %q", user)
|
||||||
@@ -37,13 +69,26 @@ func StartPTY(user string) (*PTY, error) {
|
|||||||
return nil, fmt.Errorf("su не найден: %w", err)
|
return nil, fmt.Errorf("su не найден: %w", err)
|
||||||
}
|
}
|
||||||
cmd = exec.Command(su, "-l", user)
|
cmd = exec.Command(su, "-l", user)
|
||||||
|
// su -l goes to the user's home; starting there too keeps the shell
|
||||||
|
// out of "/" should it not.
|
||||||
|
if home := homeDir(user); isDir(home) {
|
||||||
|
cmd.Dir = home
|
||||||
}
|
}
|
||||||
cmd.Env = append(os.Environ(), "TERM=xterm-256color")
|
}
|
||||||
|
cmd.Env = env
|
||||||
f, err := pty.Start(cmd)
|
f, err := pty.Start(cmd)
|
||||||
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 {
|
||||||
|
info, err := os.Stat(p)
|
||||||
|
return err == nil && info.IsDir()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read reads terminal output.
|
// Read reads terminal output.
|
||||||
@@ -60,12 +105,24 @@ func (p *PTY) Resize(cols, rows uint16) {
|
|||||||
_ = pty.Setsize(p.f, &pty.Winsize{Rows: rows, Cols: cols})
|
_ = pty.Setsize(p.f, &pty.Winsize{Rows: rows, Cols: cols})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close terminates the shell and releases the PTY.
|
// Close hangs the terminal up as a closed window would: the shell gets
|
||||||
|
// SIGHUP, saves its history and exits. One that does not within hangupGrace
|
||||||
|
// is killed. Safe to call more than once.
|
||||||
func (p *PTY) Close() error {
|
func (p *PTY) Close() error {
|
||||||
err := p.f.Close()
|
var err error
|
||||||
if p.cmd.Process != nil {
|
p.once.Do(func() {
|
||||||
_ = p.cmd.Process.Kill()
|
// Closing the master hangs up the session; the signal to the
|
||||||
|
// shell's process group makes sure it hears, through su as well.
|
||||||
|
err = p.f.Close()
|
||||||
|
pid := p.cmd.Process.Pid
|
||||||
|
_ = syscall.Kill(-pid, syscall.SIGHUP)
|
||||||
|
select {
|
||||||
|
case <-p.exited:
|
||||||
|
case <-time.After(hangupGrace):
|
||||||
|
_ = syscall.Kill(-pid, syscall.SIGKILL)
|
||||||
|
<-p.exited
|
||||||
}
|
}
|
||||||
_ = p.cmd.Wait()
|
openPTYs.Delete(p)
|
||||||
|
})
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -26,9 +26,7 @@ func (a *Agent) runTunnel(ctx context.Context, addr string) {
|
|||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if backoff < 15*time.Second {
|
backoff = min(backoff*2, retryCap)
|
||||||
backoff *= 2
|
|
||||||
}
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
backoff = time.Second
|
backoff = time.Second
|
||||||
@@ -96,8 +94,14 @@ func (t *tunClient) write(f tunnel.Frame) error {
|
|||||||
return tunnel.WriteFrame(t.conn, f)
|
return tunnel.WriteFrame(t.conn, f)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tunnelSilence is how long the tunnel may say nothing before it is taken
|
||||||
|
// for dead: the server pings every 20 s, so this is three missed pings - a
|
||||||
|
// connection a NAT forgot while the machine slept, say.
|
||||||
|
const tunnelSilence = 65 * time.Second
|
||||||
|
|
||||||
func (t *tunClient) readLoop() error {
|
func (t *tunClient) readLoop() error {
|
||||||
for {
|
for {
|
||||||
|
_ = t.conn.SetReadDeadline(time.Now().Add(tunnelSilence))
|
||||||
f, err := tunnel.ReadFrame(t.conn)
|
f, err := tunnel.ReadFrame(t.conn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.shutdown()
|
t.shutdown()
|
||||||
@@ -207,9 +211,7 @@ func (a *Agent) runUDP(ctx context.Context, addr string) {
|
|||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if backoff < 15*time.Second {
|
backoff = min(backoff*2, retryCap)
|
||||||
backoff *= 2
|
|
||||||
}
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
backoff = time.Second
|
backoff = time.Second
|
||||||
|
|||||||
+117
-24
@@ -3,31 +3,40 @@ package agent
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||||
)
|
)
|
||||||
|
|
||||||
// doUpdate downloads the latest agent binary for this platform, atomically
|
// doUpdate downloads the latest agent binary for this platform, replaces the
|
||||||
// replaces the running executable, and schedules a service restart. It never
|
// running executable and restarts the service. It runs as root when it can:
|
||||||
// uninstalls - only updates.
|
// the agent's own user if that is root, otherwise sudo when it needs no
|
||||||
|
// password. It never uninstalls - only updates.
|
||||||
func (a *Agent) doUpdate() proto.TaskResult {
|
func (a *Agent) doUpdate() proto.TaskResult {
|
||||||
exe, err := os.Executable()
|
exe, err := os.Executable()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return proto.TaskResult{Error: "не удалось определить путь бинарника: " + err.Error()}
|
return proto.TaskResult{Error: "не удалось определить путь бинарника: " + err.Error()}
|
||||||
}
|
}
|
||||||
exe, _ = filepath.EvalSymlinks(exe)
|
exe, _ = filepath.EvalSymlinks(exe)
|
||||||
|
sudo := ""
|
||||||
|
if os.Geteuid() != 0 {
|
||||||
|
sudo = sudoPath()
|
||||||
|
}
|
||||||
|
|
||||||
url := fmt.Sprintf("%s/download/agent-%s-%s", a.cfg.Host, runtime.GOOS, runtime.GOARCH)
|
url := fmt.Sprintf("%s/download/agent-%s-%s", a.host(), runtime.GOOS, runtime.GOARCH)
|
||||||
req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
|
req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
|
||||||
resp, err := a.http.Do(req)
|
resp, err := a.stream.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return proto.TaskResult{Error: "скачивание: " + err.Error()}
|
return proto.TaskResult{Error: "скачивание: " + err.Error()}
|
||||||
}
|
}
|
||||||
@@ -36,41 +45,125 @@ func (a *Agent) doUpdate() proto.TaskResult {
|
|||||||
return proto.TaskResult{Error: "скачивание: сервер вернул " + resp.Status}
|
return proto.TaskResult{Error: "скачивание: сервер вернул " + resp.Status}
|
||||||
}
|
}
|
||||||
|
|
||||||
tmp := exe + ".new"
|
// The new binary lands next to the old one, or, when root's hand is
|
||||||
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755)
|
// needed to put it there, in a temporary folder first.
|
||||||
if err != nil {
|
dir := filepath.Dir(exe)
|
||||||
return proto.TaskResult{Error: "создание файла: " + err.Error()}
|
if sudo != "" {
|
||||||
|
dir = ""
|
||||||
}
|
}
|
||||||
if _, err := io.Copy(f, resp.Body); err != nil {
|
f, err := os.CreateTemp(dir, ".lpagent-new-*")
|
||||||
f.Close()
|
if err != nil {
|
||||||
os.Remove(tmp)
|
return proto.TaskResult{Error: "создание файла: " + err.Error() + userNote()}
|
||||||
|
}
|
||||||
|
tmp := f.Name()
|
||||||
|
defer os.Remove(tmp)
|
||||||
|
n, err := io.Copy(f, io.LimitReader(resp.Body, 256<<20))
|
||||||
|
if cerr := f.Close(); err == nil {
|
||||||
|
err = cerr
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
return proto.TaskResult{Error: "запись: " + err.Error()}
|
return proto.TaskResult{Error: "запись: " + err.Error()}
|
||||||
}
|
}
|
||||||
f.Close()
|
if err := checkBinary(tmp, n); err != nil {
|
||||||
|
return proto.TaskResult{Error: err.Error()}
|
||||||
|
}
|
||||||
|
if err := os.Chmod(tmp, 0o755); err != nil {
|
||||||
|
return proto.TaskResult{Error: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
// Atomic replace: renaming over a running executable is allowed on Linux;
|
how := "от " + currentUser()
|
||||||
// the live process keeps the old inode until it restarts.
|
if sudo != "" {
|
||||||
if err := os.Rename(tmp, exe); err != nil {
|
// install copies it in place under a new inode, as a rename would.
|
||||||
os.Remove(tmp)
|
if out, err := exec.Command(sudo, "-n", "install", "-m", "0755", tmp, exe).CombinedOutput(); err != nil {
|
||||||
return proto.TaskResult{Error: "замена бинарника: " + err.Error()}
|
return proto.TaskResult{Error: "замена бинарника через sudo: " + strings.TrimSpace(string(out)+" "+err.Error())}
|
||||||
|
}
|
||||||
|
how = "через sudo"
|
||||||
|
} else if err := os.Rename(tmp, exe); err != nil {
|
||||||
|
// Renaming over a running executable is allowed; the live process
|
||||||
|
// keeps the old inode until it restarts.
|
||||||
|
return proto.TaskResult{Error: "замена бинарника: " + err.Error() + userNote()}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restart shortly, after the result has been reported.
|
// Restart shortly, after the result has been reported.
|
||||||
go func() {
|
go func() {
|
||||||
time.Sleep(1500 * time.Millisecond)
|
time.Sleep(1500 * time.Millisecond)
|
||||||
restartSelf()
|
restartSelf(exe, sudo)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
data, _ := json.Marshal(map[string]string{"status": "обновлено, перезапуск"})
|
data, _ := json.Marshal(map[string]string{"status": "обновлено " + how + ", перезапуск"})
|
||||||
return proto.TaskResult{OK: true, Data: data}
|
return proto.TaskResult{OK: true, Data: data}
|
||||||
}
|
}
|
||||||
|
|
||||||
// restartSelf restarts the agent so the new binary takes effect.
|
// sudoPath is sudo when it lets this user run commands without a password.
|
||||||
func restartSelf() {
|
func sudoPath() string {
|
||||||
|
p, err := exec.LookPath("sudo")
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if exec.Command(p, "-n", "true").Run() != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func userNote() string {
|
||||||
|
if os.Geteuid() == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return " (агент запущен от " + currentUser() + ", без root и sudo без пароля - обновите установщиком)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkBinary makes sure what came is a program for this system, not an
|
||||||
|
// error page: a binary of a sensible size with the right header.
|
||||||
|
func checkBinary(path string, size int64) error {
|
||||||
|
if size < 1<<20 {
|
||||||
|
return fmt.Errorf("скачанный файл слишком мал (%d байт) - не похоже на агента", size)
|
||||||
|
}
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
var magic [4]byte
|
||||||
|
if _, err := io.ReadFull(f, magic[:]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ok := false
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "linux":
|
||||||
|
ok = string(magic[:]) == "\x7fELF"
|
||||||
|
case "darwin":
|
||||||
|
ok = magic == [4]byte{0xcf, 0xfa, 0xed, 0xfe} || magic == [4]byte{0xca, 0xfe, 0xba, 0xbe}
|
||||||
|
default:
|
||||||
|
ok = true
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return errors.New("скачанный файл - не программа для " + runtime.GOOS)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// restartSelf starts the new binary. Under systemd the service is restarted
|
||||||
|
// (queued, so it goes on after this process is stopped); otherwise the
|
||||||
|
// process replaces itself with the new binary.
|
||||||
|
func restartSelf(exe, sudo string) {
|
||||||
|
if os.Getenv("INVOCATION_ID") != "" {
|
||||||
if path, err := exec.LookPath("systemctl"); err == nil {
|
if path, err := exec.LookPath("systemctl"); err == nil {
|
||||||
_ = exec.Command(path, "restart", "lpagent").Start()
|
args := []string{"--no-block", "restart", "lpagent"}
|
||||||
|
cmd := exec.Command(path, args...)
|
||||||
|
if sudo != "" {
|
||||||
|
cmd = exec.Command(sudo, append([]string{"-n", path}, args...)...)
|
||||||
|
}
|
||||||
|
if cmd.Run() == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// No systemd (e.g. launchd/manual run): exit so the supervisor restarts us.
|
}
|
||||||
|
// systemd brings a service that stopped back up (Restart=always).
|
||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
}
|
}
|
||||||
|
CloseTerminals()
|
||||||
|
if err := syscall.Exec(exe, os.Args, os.Environ()); err != nil {
|
||||||
|
log.Printf("update: restart: %v", err)
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,8 +18,23 @@ const (
|
|||||||
CtrlOpenForward = "open_forward"
|
CtrlOpenForward = "open_forward"
|
||||||
// CtrlTask tells the agent to fetch and run the one-shot task TaskID.
|
// CtrlTask tells the agent to fetch and run the one-shot task TaskID.
|
||||||
CtrlTask = "task"
|
CtrlTask = "task"
|
||||||
|
// CtrlOpenSSH tells the agent to open an SSH connection to SSH and bridge
|
||||||
|
// its shell to the session id, as a terminal.
|
||||||
|
CtrlOpenSSH = "open_ssh"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// SSHTarget is where an SSH terminal connects and how it signs in: a
|
||||||
|
// password, a private key (PEM or OpenSSH, with its passphrase if it has
|
||||||
|
// one), or, with neither, the agent user's own keys in ~/.ssh.
|
||||||
|
type SSHTarget struct {
|
||||||
|
Host string `json:"host"`
|
||||||
|
Port int `json:"port,omitempty"`
|
||||||
|
User string `json:"user"`
|
||||||
|
Password string `json:"password,omitempty"`
|
||||||
|
Key string `json:"key,omitempty"`
|
||||||
|
Passphrase string `json:"passphrase,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// EnrollRequest is sent once by a freshly installed agent.
|
// EnrollRequest is sent once by a freshly installed agent.
|
||||||
type EnrollRequest struct {
|
type EnrollRequest struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -43,6 +58,44 @@ type ControlMessage struct {
|
|||||||
Port int `json:"port,omitempty"` // CtrlOpenForward: client-side port to dial
|
Port int `json:"port,omitempty"` // CtrlOpenForward: client-side port to dial
|
||||||
TaskID string `json:"task_id,omitempty"` // CtrlTask
|
TaskID string `json:"task_id,omitempty"` // CtrlTask
|
||||||
User string `json:"user,omitempty"` // CtrlOpenTerminal: OS user for the shell ("" = the agent's own)
|
User string `json:"user,omitempty"` // CtrlOpenTerminal: OS user for the shell ("" = the agent's own)
|
||||||
|
SSH *SSHTarget `json:"ssh,omitempty"` // CtrlOpenSSH
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transports an agent can reach the server by.
|
||||||
|
const (
|
||||||
|
TransportAuto = "auto" // WebSocket, long polling while it cannot connect
|
||||||
|
TransportWS = "ws" // WebSocket only
|
||||||
|
TransportLP = "lp" // long polling only
|
||||||
|
)
|
||||||
|
|
||||||
|
// TransportHeader carries the agent's transport setting on poll and config
|
||||||
|
// answers.
|
||||||
|
const TransportHeader = "X-Transport"
|
||||||
|
|
||||||
|
// FallbackHeader carries the server's direct address (one not behind a
|
||||||
|
// proxy like Cloudflare) on poll and config answers, for the agent to turn
|
||||||
|
// to when the main one is out of reach.
|
||||||
|
const FallbackHeader = "X-Fallback-Host"
|
||||||
|
|
||||||
|
// MainHeader carries the server's main address beside FallbackHeader, for an
|
||||||
|
// agent set to the direct one to fall back to the main one.
|
||||||
|
const MainHeader = "X-Main-Host"
|
||||||
|
|
||||||
|
// WSMsg is one message on an agent's WebSocket, either way. Server to agent:
|
||||||
|
// "ctrl" (Ctrl, as a control poll would answer), "cfg" (Tunnel and
|
||||||
|
// Transport, also a keep-alive), "in" (Frames for session SID), "gone"
|
||||||
|
// (session SID is over). Agent to server: "sub" (send the input of session
|
||||||
|
// SID here), "out" (Out of session SID).
|
||||||
|
type WSMsg struct {
|
||||||
|
T string `json:"t"`
|
||||||
|
Ctrl *ControlMessage `json:"ctrl,omitempty"`
|
||||||
|
SID string `json:"sid,omitempty"`
|
||||||
|
Frames []InFrame `json:"frames,omitempty"`
|
||||||
|
Out *OutMessage `json:"out,omitempty"`
|
||||||
|
Tunnel string `json:"tunnel,omitempty"`
|
||||||
|
Transport string `json:"transport,omitempty"`
|
||||||
|
Fallback string `json:"fallback,omitempty"`
|
||||||
|
Main string `json:"main,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Terminal / stream frame types on the session "in" channel (server -> agent).
|
// Terminal / stream frame types on the session "in" channel (server -> agent).
|
||||||
@@ -70,8 +123,20 @@ type InBatch struct {
|
|||||||
type OutMessage struct {
|
type OutMessage struct {
|
||||||
Data string `json:"data,omitempty"` // base64 bytes
|
Data string `json:"data,omitempty"` // base64 bytes
|
||||||
Exit bool `json:"exit,omitempty"`
|
Exit bool `json:"exit,omitempty"`
|
||||||
|
// Status tells the panel how an SSH terminal's connection goes: one of
|
||||||
|
// the SSHStatus values, with Note to show.
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
Note string `json:"note,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The steps of an SSH terminal's connection, as OutMessage.Status.
|
||||||
|
const (
|
||||||
|
SSHDial = "dial" // connecting to the host
|
||||||
|
SSHAuth = "auth" // the host answered, signing in
|
||||||
|
SSHOK = "ok" // signed in, the shell runs
|
||||||
|
SSHErr = "err" // it failed; Note says why
|
||||||
|
)
|
||||||
|
|
||||||
// --- one-shot tasks (file manager, native in the agent) ---
|
// --- one-shot tasks (file manager, native in the agent) ---
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -85,18 +150,44 @@ 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
|
||||||
|
TaskFSSend = "fs.send" // send a file to the browser in pieces
|
||||||
|
|
||||||
TaskSysUpdate = "sys.update" // download the latest agent binary and restart
|
TaskSysUpdate = "sys.update" // download the latest agent binary and restart
|
||||||
TaskSysUsers = "sys.users" // report the agent's user and the login-capable users
|
TaskSysUsers = "sys.users" // report the agent's user and the login-capable users
|
||||||
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
|
||||||
// file be edited only when it fits, so that a save never cuts it short.
|
// file be edited only when it fits, so that a save never cuts it short.
|
||||||
const FSReadCap = 5 * 1024 * 1024
|
const FSReadCap = 5 * 1024 * 1024
|
||||||
|
|
||||||
|
// FSFileCap bounds a file sent either way in pieces (TaskFSPut, TaskFSSend).
|
||||||
|
const FSFileCap = 512 << 20
|
||||||
|
|
||||||
|
// FSXferArgs are the arguments of TaskFSPut and TaskFSSend: the file, and the
|
||||||
|
// transfer on the server whose pieces carry it - GET them for a put, POST them
|
||||||
|
// for a send, Chunk bytes each. Size is a put's length; -1 for a send.
|
||||||
|
type FSXferArgs struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Xfer string `json:"xfer"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
Chunk int `json:"chunk"`
|
||||||
|
}
|
||||||
|
|
||||||
// TaskEnvelope is what the agent fetches for a task id.
|
// TaskEnvelope is what the agent fetches for a task id.
|
||||||
type TaskEnvelope struct {
|
type TaskEnvelope struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
@@ -131,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"`
|
||||||
@@ -200,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
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package server
|
|||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
lpra "github.com/x-grk/longpolling-remote-access"
|
lpra "github.com/x-grk/longpolling-remote-access"
|
||||||
@@ -14,9 +15,11 @@ const (
|
|||||||
cfgEnrollSecret = "enroll_secret"
|
cfgEnrollSecret = "enroll_secret"
|
||||||
cfgSetupDone = "setup_done"
|
cfgSetupDone = "setup_done"
|
||||||
cfgInstallCode = "install_code"
|
cfgInstallCode = "install_code"
|
||||||
|
cfgInstallLen = "install_code_len"
|
||||||
cfgUfwEnabled = "ufw_enabled"
|
cfgUfwEnabled = "ufw_enabled"
|
||||||
cfgTunnelHost = "tunnel_host"
|
cfgTunnelHost = "tunnel_host"
|
||||||
cfgTunnelPort = "tunnel_port"
|
cfgTunnelPort = "tunnel_port"
|
||||||
|
cfgDirectAddr = "direct_address"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DefaultHostAddress is the public URL agents dial by default. It is baked into
|
// DefaultHostAddress is the public URL agents dial by default. It is baked into
|
||||||
@@ -52,7 +55,7 @@ func (db *DB) ensureDefaults() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if code == "" {
|
if code == "" {
|
||||||
if err := db.SetConfig(cfgInstallCode, shortCode()); err != nil {
|
if err := db.SetConfig(cfgInstallCode, shortCode(db.InstallCodeLen())); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,6 +70,23 @@ func (db *DB) HostAddress() string {
|
|||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DirectAddress is the server's direct URL, one that is not behind a proxy
|
||||||
|
// like Cloudflare: the agents' fallback, and a way to install when the main
|
||||||
|
// address is out of reach. Empty when not set.
|
||||||
|
func (db *DB) DirectAddress() string {
|
||||||
|
v, _ := db.GetConfig(cfgDirectAddr)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// directHost is the direct address's host name, without scheme and port.
|
||||||
|
func directHost(addr string) string {
|
||||||
|
u, err := url.Parse(addr)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return u.Hostname()
|
||||||
|
}
|
||||||
|
|
||||||
func (db *DB) EnrollSecret() string {
|
func (db *DB) EnrollSecret() string {
|
||||||
v, _ := db.GetConfig(cfgEnrollSecret)
|
v, _ := db.GetConfig(cfgEnrollSecret)
|
||||||
return v
|
return v
|
||||||
@@ -125,16 +145,36 @@ func randToken() string {
|
|||||||
return hex.EncodeToString(b)
|
return hex.EncodeToString(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
// shortCode returns a short, URL-safe, typo-resistant code for install links.
|
// Install code length: 3 by default, up to 24.
|
||||||
// It avoids ambiguous characters (0/O, 1/l/I) so it is easy to read and type.
|
const (
|
||||||
func shortCode() string {
|
installCodeMin = 3
|
||||||
const alphabet = "abcdefghjkmnpqrstuvwxyz23456789"
|
installCodeMax = 24
|
||||||
b := make([]byte, 7)
|
installCodeDef = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
// InstallCodeLen is how many characters a new install code has.
|
||||||
|
func (db *DB) InstallCodeLen() int {
|
||||||
|
v, _ := db.GetConfig(cfgInstallLen)
|
||||||
|
if n, err := strconv.Atoi(v); err == nil && n >= installCodeMin && n <= installCodeMax {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
return installCodeDef
|
||||||
|
}
|
||||||
|
|
||||||
|
// shortCode returns a random code of n English letters and digits for the
|
||||||
|
// /i/<code> install link. Every character is equally likely.
|
||||||
|
func shortCode(n int) string {
|
||||||
|
const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||||
|
out := make([]byte, 0, n)
|
||||||
|
b := make([]byte, 1)
|
||||||
|
for len(out) < n {
|
||||||
if _, err := rand.Read(b); err != nil {
|
if _, err := rand.Read(b); err != nil {
|
||||||
panic("crypto/rand failed: " + err.Error())
|
panic("crypto/rand failed: " + err.Error())
|
||||||
}
|
}
|
||||||
for i := range b {
|
// 248 = 4 * 62: drop the rest, so no character comes up more often.
|
||||||
b[i] = alphabet[int(b[i])%len(alphabet)]
|
if b[0] < 248 {
|
||||||
|
out = append(out, alphabet[int(b[0])%len(alphabet)])
|
||||||
}
|
}
|
||||||
return string(b)
|
}
|
||||||
|
return string(out)
|
||||||
}
|
}
|
||||||
+272
-19
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,623 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"path"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Files go between the browser and the agent in pieces. Each piece is a
|
||||||
|
// request of its own, small enough for any proxy in front (Cloudflare takes
|
||||||
|
// 100 MB a request at most), and one that fails is sent again instead of the
|
||||||
|
// whole file. Several pieces are in flight at once, so a good link stays as
|
||||||
|
// busy as it would with one long stream. The server holds only a few pieces
|
||||||
|
// of a transfer: whoever sends waits while they wait to be taken.
|
||||||
|
//
|
||||||
|
// A download keeps one plain response towards the browser, which a proxy
|
||||||
|
// passes on whatever its size: links, previews and the hex editor read it.
|
||||||
|
|
||||||
|
const (
|
||||||
|
// xferChunk is the size of a piece.
|
||||||
|
xferChunk = 4 << 20
|
||||||
|
// xferBuffer is how many pieces of a transfer the server holds.
|
||||||
|
xferBuffer = 8
|
||||||
|
// xferWait is how long a request waits for its piece, or for room for it,
|
||||||
|
// before it is told to ask again: well under a proxy's timeout.
|
||||||
|
xferWait = 50 * time.Second
|
||||||
|
// xferIdle ends a transfer nobody has touched for this long.
|
||||||
|
xferIdle = 10 * time.Minute
|
||||||
|
// xferTimeout bounds one transfer, however slow the links.
|
||||||
|
xferTimeout = 2 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
// fsLegacyCap is the most an agent that predates the pieces can take in or
|
||||||
|
// hand over: the file goes base64 inside a task then.
|
||||||
|
const fsLegacyCap = 32 << 20
|
||||||
|
|
||||||
|
// xfer is one file on its way.
|
||||||
|
type xfer struct {
|
||||||
|
id, agentID string
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
changed chan struct{} // closed and replaced on every change
|
||||||
|
size int64 // -1 until known
|
||||||
|
n int // pieces in all, once the size is known
|
||||||
|
chunks map[int][]byte
|
||||||
|
// low is the first piece still to go: the ones below it were written by
|
||||||
|
// the agent (upload) or to the browser (download).
|
||||||
|
low int
|
||||||
|
sized chan struct{} // closed once the size is known
|
||||||
|
err string // why the transfer failed
|
||||||
|
done bool
|
||||||
|
result proto.TaskResult
|
||||||
|
last time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func pieces(size int64) int {
|
||||||
|
if size <= 0 {
|
||||||
|
return 1 // an empty file is one empty piece
|
||||||
|
}
|
||||||
|
return int((size + xferChunk - 1) / xferChunk)
|
||||||
|
}
|
||||||
|
|
||||||
|
// pieceLen is how long piece i of the file is.
|
||||||
|
func (x *xfer) pieceLen(i int) int64 {
|
||||||
|
return min(int64(xferChunk), x.size-int64(i)*xferChunk)
|
||||||
|
}
|
||||||
|
|
||||||
|
// notify wakes whoever waits on the transfer; x.mu is held.
|
||||||
|
func (x *xfer) notify() {
|
||||||
|
x.last = time.Now()
|
||||||
|
close(x.changed)
|
||||||
|
x.changed = make(chan struct{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// await waits until ready() holds and returns true with x.mu held; false,
|
||||||
|
// with it free, when the wait ran out or ctx ended.
|
||||||
|
func (x *xfer) await(ctx context.Context, d time.Duration, ready func() bool) bool {
|
||||||
|
t := time.NewTimer(d)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
x.mu.Lock()
|
||||||
|
if ready() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
ch := x.changed
|
||||||
|
x.mu.Unlock()
|
||||||
|
select {
|
||||||
|
case <-ch:
|
||||||
|
case <-t.C:
|
||||||
|
return false
|
||||||
|
case <-ctx.Done():
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *xfer) fail(msg string) {
|
||||||
|
x.mu.Lock()
|
||||||
|
if x.err == "" && !x.done {
|
||||||
|
x.err = msg
|
||||||
|
x.chunks = nil
|
||||||
|
x.notify()
|
||||||
|
}
|
||||||
|
x.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *xfer) finish(res proto.TaskResult) {
|
||||||
|
x.mu.Lock()
|
||||||
|
x.done, x.result, x.chunks = true, res, nil
|
||||||
|
x.notify()
|
||||||
|
x.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// setSize records the file's size; false when it differs from the one known.
|
||||||
|
func (x *xfer) setSize(size int64) bool {
|
||||||
|
x.mu.Lock()
|
||||||
|
defer x.mu.Unlock()
|
||||||
|
if x.size >= 0 {
|
||||||
|
return x.size == size
|
||||||
|
}
|
||||||
|
x.size, x.n = size, pieces(size)
|
||||||
|
close(x.sized)
|
||||||
|
x.notify()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// put stores piece i once there is room for it, and answers with the HTTP
|
||||||
|
// status for its sender. A piece already taken or held is accepted again: it
|
||||||
|
// is a retry.
|
||||||
|
func (x *xfer) put(ctx context.Context, i int, data []byte) (int, string) {
|
||||||
|
if !x.await(ctx, xferWait, func() bool { return x.err != "" || x.done || i < x.low+xferBuffer }) {
|
||||||
|
return http.StatusServiceUnavailable, "нет места, повторите"
|
||||||
|
}
|
||||||
|
defer x.mu.Unlock()
|
||||||
|
if x.err != "" || x.done {
|
||||||
|
return http.StatusGone, x.gone()
|
||||||
|
}
|
||||||
|
if i < 0 || i >= x.n || int64(len(data)) != x.pieceLen(i) {
|
||||||
|
return http.StatusBadRequest, "не тот кусок"
|
||||||
|
}
|
||||||
|
if i >= x.low && x.chunks[i] == nil {
|
||||||
|
x.chunks[i] = data
|
||||||
|
x.notify()
|
||||||
|
}
|
||||||
|
return http.StatusNoContent, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// take hands over piece i, the next one in order, and lets it go.
|
||||||
|
func (x *xfer) take(ctx context.Context, i int, d time.Duration) ([]byte, error) {
|
||||||
|
if !x.await(ctx, d, func() bool { return x.err != "" || x.chunks[i] != nil }) {
|
||||||
|
return nil, errors.New("кусок не пришёл вовремя")
|
||||||
|
}
|
||||||
|
defer x.mu.Unlock()
|
||||||
|
if x.err != "" {
|
||||||
|
return nil, errors.New(x.err)
|
||||||
|
}
|
||||||
|
data := x.chunks[i]
|
||||||
|
delete(x.chunks, i)
|
||||||
|
x.low = i + 1
|
||||||
|
x.notify()
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *xfer) gone() string {
|
||||||
|
if x.err != "" {
|
||||||
|
return x.err
|
||||||
|
}
|
||||||
|
return "передача закончена"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read makes an upload a plain reader, for an agent that takes a file whole.
|
||||||
|
type xferReader struct {
|
||||||
|
ctx context.Context
|
||||||
|
x *xfer
|
||||||
|
i int
|
||||||
|
buf []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *xferReader) Read(p []byte) (int, error) {
|
||||||
|
for len(r.buf) == 0 {
|
||||||
|
if r.i >= r.x.n {
|
||||||
|
return 0, io.EOF
|
||||||
|
}
|
||||||
|
data, err := r.x.take(r.ctx, r.i, xferIdle)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
r.buf, r.i = data, r.i+1
|
||||||
|
}
|
||||||
|
n := copy(p, r.buf)
|
||||||
|
r.buf = r.buf[n:]
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type xfers struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
m map[string]*xfer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *xfers) open(agentID string, size int64) *xfer {
|
||||||
|
x := &xfer{
|
||||||
|
id: randToken()[:24], agentID: agentID, size: -1,
|
||||||
|
changed: make(chan struct{}), chunks: map[int][]byte{},
|
||||||
|
sized: make(chan struct{}), last: time.Now(),
|
||||||
|
}
|
||||||
|
if size >= 0 {
|
||||||
|
x.size, x.n = size, pieces(size)
|
||||||
|
close(x.sized)
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
if c.m == nil {
|
||||||
|
c.m = map[string]*xfer{}
|
||||||
|
}
|
||||||
|
// Forget the ones left behind: a closed tab, a client gone for good.
|
||||||
|
for id, old := range c.m {
|
||||||
|
old.mu.Lock()
|
||||||
|
stale := time.Since(old.last) > xferIdle
|
||||||
|
old.mu.Unlock()
|
||||||
|
if stale {
|
||||||
|
old.fail("передача брошена")
|
||||||
|
delete(c.m, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.m[x.id] = x
|
||||||
|
c.mu.Unlock()
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *xfers) get(id string) *xfer {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
return c.m[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
// forAgent returns the transfer if it is the asking agent's.
|
||||||
|
func (c *xfers) forAgent(id, agentID string) *xfer {
|
||||||
|
if x := c.get(id); x != nil && x.agentID == agentID {
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *xfers) drop(id string) {
|
||||||
|
c.mu.Lock()
|
||||||
|
delete(c.m, id)
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitTask runs a task on the agent and waits for its result, or for ctx to
|
||||||
|
// end.
|
||||||
|
func (s *Server) waitTask(ctx context.Context, agentID, typ string, args any, timeout time.Duration) (proto.TaskResult, error) {
|
||||||
|
raw, _ := json.Marshal(args)
|
||||||
|
taskID, ch := s.hub.CreateTask(agentID, typ, raw)
|
||||||
|
defer s.hub.DiscardTask(taskID)
|
||||||
|
t := time.NewTimer(timeout)
|
||||||
|
defer t.Stop()
|
||||||
|
select {
|
||||||
|
case res := <-ch:
|
||||||
|
return res, nil
|
||||||
|
case <-ctx.Done():
|
||||||
|
return proto.TaskResult{}, ctx.Err()
|
||||||
|
case <-t.C:
|
||||||
|
return proto.TaskResult{}, errors.New("таймаут ожидания агента")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// unknownOp tells an agent that predates the task.
|
||||||
|
func unknownOp(res proto.TaskResult) bool {
|
||||||
|
return !res.OK && strings.Contains(res.Error, "неизвестная операция")
|
||||||
|
}
|
||||||
|
|
||||||
|
// fsAgent answers the request itself and returns false when the agent is
|
||||||
|
// unknown or offline.
|
||||||
|
func (s *Server) fsAgent(w http.ResponseWriter, agentID string, asJSON bool) bool {
|
||||||
|
a, err := s.db.GetAgent(agentID)
|
||||||
|
if err != nil || a == nil {
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !s.online(a) {
|
||||||
|
if asJSON {
|
||||||
|
writeJSON(w, proto.TaskResult{Error: "машина офлайн"})
|
||||||
|
} else {
|
||||||
|
http.Error(w, "машина офлайн", http.StatusServiceUnavailable)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// readPiece reads a piece's body into a buffer of its own size, so pieces
|
||||||
|
// leave no trail of grown buffers behind.
|
||||||
|
func readPiece(w http.ResponseWriter, r *http.Request) ([]byte, error) {
|
||||||
|
if r.ContentLength >= 0 && r.ContentLength <= xferChunk {
|
||||||
|
data := make([]byte, r.ContentLength)
|
||||||
|
_, err := io.ReadFull(r.Body, data)
|
||||||
|
return data, err
|
||||||
|
}
|
||||||
|
return io.ReadAll(http.MaxBytesReader(w, r.Body, xferChunk))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- upload: browser -> agent ----------
|
||||||
|
|
||||||
|
// POST /api/panel/agents/{id}/fs/upload?path=...&size=N - start writing a
|
||||||
|
// file. The answer names the transfer and the piece size; the browser then
|
||||||
|
// PUTs the pieces and asks for the result.
|
||||||
|
func (s *Server) handleFSUploadStart(w http.ResponseWriter, r *http.Request) {
|
||||||
|
agentID, p := r.PathValue("id"), r.URL.Query().Get("path")
|
||||||
|
if !s.fsAgent(w, agentID, true) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
size, err := strconv.ParseInt(r.URL.Query().Get("size"), 10, 64)
|
||||||
|
if p == "" || err != nil || size < 0 {
|
||||||
|
writeJSON(w, proto.TaskResult{Error: "не указан путь или размер"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if size > proto.FSFileCap {
|
||||||
|
writeJSON(w, proto.TaskResult{Error: "файл больше 512 МБ"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
x := s.xfers.open(agentID, size)
|
||||||
|
go s.runUpload(x, p)
|
||||||
|
data, _ := json.Marshal(map[string]any{"id": x.id, "chunk": xferChunk})
|
||||||
|
writeJSON(w, proto.TaskResult{OK: true, Data: data})
|
||||||
|
}
|
||||||
|
|
||||||
|
// runUpload has the agent write the file and keeps the result for the
|
||||||
|
// browser to collect.
|
||||||
|
func (s *Server) runUpload(x *xfer, p string) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), xferTimeout)
|
||||||
|
defer cancel()
|
||||||
|
args := proto.FSXferArgs{Path: p, Xfer: x.id, Size: x.size, Chunk: xferChunk}
|
||||||
|
res, err := s.waitTask(ctx, x.agentID, proto.TaskFSPut, args, xferTimeout)
|
||||||
|
if err == nil && unknownOp(res) {
|
||||||
|
res = s.legacyUpload(ctx, x, p)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
res = proto.TaskResult{Error: err.Error()}
|
||||||
|
}
|
||||||
|
if !res.OK {
|
||||||
|
x.fail(res.Error)
|
||||||
|
}
|
||||||
|
x.finish(res)
|
||||||
|
// The result waits a while for the browser to come for it.
|
||||||
|
time.AfterFunc(xferIdle, func() { s.xfers.drop(x.id) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// legacyUpload writes a file through an agent that predates the pieces.
|
||||||
|
func (s *Server) legacyUpload(ctx context.Context, x *xfer, p string) proto.TaskResult {
|
||||||
|
if x.size > fsLegacyCap {
|
||||||
|
return proto.TaskResult{Error: "клиент принимает файлы до 32 МБ - обновите его"}
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(&xferReader{ctx: ctx, x: x})
|
||||||
|
if err != nil {
|
||||||
|
return proto.TaskResult{Error: err.Error()}
|
||||||
|
}
|
||||||
|
args := proto.FSWriteArgs{Path: p, Data: base64.StdEncoding.EncodeToString(data)}
|
||||||
|
res, err := s.waitTask(ctx, x.agentID, proto.TaskFSWrite, args, fsTaskTimeout)
|
||||||
|
if err != nil {
|
||||||
|
return proto.TaskResult{Error: err.Error()}
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /api/panel/fs/xfer/{xid}/{i} - piece i of a file being uploaded. 503:
|
||||||
|
// no room yet, send it again; 410: the transfer is over.
|
||||||
|
func (s *Server) handleXferPut(w http.ResponseWriter, r *http.Request) {
|
||||||
|
x := s.xfers.get(r.PathValue("xid"))
|
||||||
|
i, err := strconv.Atoi(r.PathValue("i"))
|
||||||
|
if x == nil || err != nil {
|
||||||
|
http.Error(w, "передача не найдена", http.StatusGone)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := readPiece(w, r)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "кусок не дошёл", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
code, msg := x.put(r.Context(), i, data)
|
||||||
|
if code == http.StatusNoContent {
|
||||||
|
w.WriteHeader(code)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, msg, code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/panel/fs/xfer/{xid}/cancel - the browser gives the upload up.
|
||||||
|
func (s *Server) handleXferCancel(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if x := s.xfers.get(r.PathValue("xid")); x != nil {
|
||||||
|
x.fail("отменено")
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/panel/fs/xfer/{xid}/result - the upload's result, once the agent
|
||||||
|
// has written the file; {"done":false} means ask again.
|
||||||
|
func (s *Server) handleXferResult(w http.ResponseWriter, r *http.Request) {
|
||||||
|
x := s.xfers.get(r.PathValue("xid"))
|
||||||
|
if x == nil {
|
||||||
|
writeJSON(w, map[string]any{"done": true, "result": proto.TaskResult{Error: "передача не найдена"}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !x.await(r.Context(), xferWait, func() bool { return x.done }) {
|
||||||
|
writeJSON(w, map[string]any{"done": false})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res := x.result
|
||||||
|
x.mu.Unlock()
|
||||||
|
writeJSON(w, map[string]any{"done": true, "result": res})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/agent/fs/xfer/{xid}/{i}?ack=K - piece i of the upload, for the
|
||||||
|
// agent to write. K says the agent has written every piece below it, so
|
||||||
|
// those are let go. 503: not here yet, ask again; 410: the transfer is over.
|
||||||
|
func (s *Server) handleAgentXferGet(w http.ResponseWriter, r *http.Request) {
|
||||||
|
a := s.authenticateAgent(r)
|
||||||
|
if a == nil {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
x := s.xfers.forAgent(r.PathValue("xid"), a.ID)
|
||||||
|
i, err := strconv.Atoi(r.PathValue("i"))
|
||||||
|
if x == nil || err != nil {
|
||||||
|
http.Error(w, "передача не найдена", http.StatusGone)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ack, err := strconv.Atoi(r.URL.Query().Get("ack")); err == nil {
|
||||||
|
x.mu.Lock()
|
||||||
|
if ack > x.low {
|
||||||
|
for k := x.low; k < ack; k++ {
|
||||||
|
delete(x.chunks, k)
|
||||||
|
}
|
||||||
|
x.low = ack
|
||||||
|
x.notify()
|
||||||
|
}
|
||||||
|
x.mu.Unlock()
|
||||||
|
}
|
||||||
|
if !x.await(r.Context(), xferWait, func() bool { return x.err != "" || x.chunks[i] != nil }) {
|
||||||
|
http.Error(w, "кусок ещё не пришёл", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if x.err != "" {
|
||||||
|
msg := x.err
|
||||||
|
x.mu.Unlock()
|
||||||
|
http.Error(w, msg, http.StatusGone)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := x.chunks[i]
|
||||||
|
x.mu.Unlock()
|
||||||
|
w.Header().Set("Content-Type", "application/octet-stream")
|
||||||
|
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
|
||||||
|
_, _ = w.Write(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- download: agent -> browser ----------
|
||||||
|
|
||||||
|
// inlineTypes are the files the panel shows in place rather than downloads.
|
||||||
|
var inlineTypes = map[string]string{
|
||||||
|
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif",
|
||||||
|
".webp": "image/webp", ".bmp": "image/bmp", ".ico": "image/x-icon", ".avif": "image/avif",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
".mp4": "video/mp4", ".m4v": "video/mp4", ".webm": "video/webm", ".ogv": "video/ogg", ".mov": "video/quicktime",
|
||||||
|
".mp3": "audio/mpeg", ".wav": "audio/wav", ".ogg": "audio/ogg", ".oga": "audio/ogg", ".m4a": "audio/mp4",
|
||||||
|
".aac": "audio/aac", ".flac": "audio/flac", ".opus": "audio/ogg",
|
||||||
|
".pdf": "application/pdf",
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileHeaders sets what the browser needs to download or show the file. A
|
||||||
|
// file shown in place runs nothing: no scripts in an SVG, whatever it holds.
|
||||||
|
// A PDF is the exception, as the browser's viewer will not open in a sandbox;
|
||||||
|
// it runs in that viewer, not in the panel's page.
|
||||||
|
func fileHeaders(w http.ResponseWriter, p string, inline bool) {
|
||||||
|
name := path.Base(p)
|
||||||
|
ctype := "application/octet-stream"
|
||||||
|
if t, ok := inlineTypes[strings.ToLower(path.Ext(name))]; ok && inline {
|
||||||
|
ctype = t
|
||||||
|
}
|
||||||
|
disp := "attachment"
|
||||||
|
if inline {
|
||||||
|
disp = "inline"
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", ctype)
|
||||||
|
w.Header().Set("Content-Disposition", disp+"; filename=\""+strings.ReplaceAll(name, "\"", "")+"\"")
|
||||||
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
|
if ctype != "application/pdf" {
|
||||||
|
w.Header().Set("Content-Security-Policy", "sandbox; default-src 'none'; img-src data:; style-src 'unsafe-inline'")
|
||||||
|
}
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/panel/agents/{id}/fs/download?path=...[&inline=1] - the file, as
|
||||||
|
// one response fed by the pieces the agent sends. inline=1 serves pictures,
|
||||||
|
// video and sound for the page to show.
|
||||||
|
func (s *Server) handleFSDownload(w http.ResponseWriter, r *http.Request) {
|
||||||
|
agentID, p := r.PathValue("id"), r.URL.Query().Get("path")
|
||||||
|
inline := r.URL.Query().Get("inline") != ""
|
||||||
|
if !s.fsAgent(w, agentID, false) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
x := s.xfers.open(agentID, -1)
|
||||||
|
defer s.xfers.drop(x.id)
|
||||||
|
defer x.fail("браузер ушёл") // stops the agent if the response ends early
|
||||||
|
|
||||||
|
raw, _ := json.Marshal(proto.FSXferArgs{Path: p, Xfer: x.id, Size: -1, Chunk: xferChunk})
|
||||||
|
taskID, ch := s.hub.CreateTask(agentID, proto.TaskFSSend, raw)
|
||||||
|
defer s.hub.DiscardTask(taskID)
|
||||||
|
// The agent's result: an error ends the transfer wherever it stands.
|
||||||
|
results := make(chan proto.TaskResult, 1)
|
||||||
|
go func() {
|
||||||
|
select {
|
||||||
|
case res := <-ch:
|
||||||
|
if !res.OK && !unknownOp(res) {
|
||||||
|
x.fail(res.Error)
|
||||||
|
}
|
||||||
|
results <- res
|
||||||
|
case <-r.Context().Done():
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-x.sized:
|
||||||
|
case res := <-results:
|
||||||
|
if unknownOp(res) {
|
||||||
|
s.legacyDownload(w, r, agentID, p, inline)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg := res.Error
|
||||||
|
if msg == "" {
|
||||||
|
msg = "агент не отдал файл"
|
||||||
|
}
|
||||||
|
http.Error(w, msg, http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
case <-r.Context().Done():
|
||||||
|
return
|
||||||
|
case <-time.After(fsTaskTimeout):
|
||||||
|
http.Error(w, "таймаут ожидания агента", http.StatusGatewayTimeout)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fileHeaders(w, p, inline)
|
||||||
|
w.Header().Set("Content-Length", strconv.FormatInt(x.size, 10))
|
||||||
|
rc := http.NewResponseController(w)
|
||||||
|
for i := 0; i < x.n; i++ {
|
||||||
|
data, err := x.take(r.Context(), i, xferIdle)
|
||||||
|
if err == nil {
|
||||||
|
_, err = w.Write(data)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
// Cut the response off, so the browser does not take a part of
|
||||||
|
// the file for all of it.
|
||||||
|
x.fail(err.Error())
|
||||||
|
panic(http.ErrAbortHandler)
|
||||||
|
}
|
||||||
|
_ = rc.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// legacyDownload gets a file from an agent that predates the pieces.
|
||||||
|
func (s *Server) legacyDownload(w http.ResponseWriter, r *http.Request, agentID, p string, inline bool) {
|
||||||
|
res, err := s.waitTask(r.Context(), agentID, proto.TaskFSDownload, proto.FSPathArgs{Path: p}, fsTaskTimeout)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusGatewayTimeout)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !res.OK {
|
||||||
|
http.Error(w, res.Error, http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var rr proto.FSReadResult
|
||||||
|
if err := json.Unmarshal(res.Data, &rr); err != nil {
|
||||||
|
http.Error(w, "bad result", http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, _ := base64.StdEncoding.DecodeString(rr.Content)
|
||||||
|
fileHeaders(w, p, inline)
|
||||||
|
if rr.Truncated {
|
||||||
|
w.Header().Set("X-Truncated", "1")
|
||||||
|
}
|
||||||
|
_, _ = w.Write(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/agent/fs/xfer/{xid}/{i} - piece i of the file the browser asked
|
||||||
|
// for, X-Size its whole size. 503: no room yet, send it again; 410: the
|
||||||
|
// browser is gone.
|
||||||
|
func (s *Server) handleAgentXferPost(w http.ResponseWriter, r *http.Request) {
|
||||||
|
a := s.authenticateAgent(r)
|
||||||
|
if a == nil {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
x := s.xfers.forAgent(r.PathValue("xid"), a.ID)
|
||||||
|
i, err := strconv.Atoi(r.PathValue("i"))
|
||||||
|
if x == nil || err != nil {
|
||||||
|
http.Error(w, "передача не найдена", http.StatusGone)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
size, err := strconv.ParseInt(r.Header.Get("X-Size"), 10, 64)
|
||||||
|
if err != nil || size < 0 || size > proto.FSFileCap || !x.setSize(size) {
|
||||||
|
http.Error(w, "не тот размер", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := readPiece(w, r)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "кусок не дошёл", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
code, msg := x.put(r.Context(), i, data)
|
||||||
|
if code == http.StatusNoContent {
|
||||||
|
w.WriteHeader(code)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, msg, code)
|
||||||
|
}
|
||||||
@@ -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,13 +122,18 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
|||||||
Online bool
|
Online bool
|
||||||
Seen string
|
Seen string
|
||||||
}
|
}
|
||||||
|
me := s.currentAdmin(r)
|
||||||
rows := make([]row, 0, len(agents))
|
rows := make([]row, 0, len(agents))
|
||||||
for _, a := range agents {
|
for _, a := range agents {
|
||||||
|
if !s.db.CanUseAgent(me, a.ID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
on := s.online(&a)
|
on := s.online(&a)
|
||||||
rows = append(rows, row{Agent: a, Online: on, Seen: seenText(on, a.LastSeen)})
|
rows = append(rows, row{Agent: a, Online: on, Seen: seenText(on, a.LastSeen)})
|
||||||
}
|
}
|
||||||
s.render(w, "dashboard.html", map[string]any{
|
s.render(w, "dashboard.html", map[string]any{
|
||||||
"Admin": s.currentAdmin(r),
|
"Admin": me,
|
||||||
|
"IsAdmin": me.IsAdmin(),
|
||||||
"Agents": rows,
|
"Agents": rows,
|
||||||
"CSRF": csrfToken(r),
|
"CSRF": csrfToken(r),
|
||||||
})
|
})
|
||||||
@@ -147,13 +158,20 @@ func (s *Server) handlePresence(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
type item struct {
|
type item struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Hostname string `json:"hostname"`
|
||||||
|
User string `json:"user"` // who a terminal opens as
|
||||||
Online bool `json:"online"`
|
Online bool `json:"online"`
|
||||||
Seen string `json:"seen"`
|
Seen string `json:"seen"`
|
||||||
}
|
}
|
||||||
|
me := s.currentAdmin(r)
|
||||||
out := make([]item, 0, len(agents))
|
out := make([]item, 0, len(agents))
|
||||||
for _, a := range agents {
|
for _, a := range agents {
|
||||||
|
if !s.db.CanUseAgent(me, a.ID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
on := s.online(&a)
|
on := s.online(&a)
|
||||||
out = append(out, item{ID: a.ID, Online: on, Seen: seenText(on, a.LastSeen)})
|
out = append(out, item{ID: a.ID, Name: a.Name, Hostname: a.Hostname, User: a.TerminalUser(), Online: on, Seen: seenText(on, a.LastSeen)})
|
||||||
}
|
}
|
||||||
writeJSON(w, out)
|
writeJSON(w, out)
|
||||||
}
|
}
|
||||||
@@ -199,12 +217,19 @@ func (s *Server) handleAgentEdit(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
data := map[string]any{
|
data := map[string]any{
|
||||||
"Agent": a,
|
"Agent": a,
|
||||||
|
"IsAdmin": s.currentAdmin(r).IsAdmin(),
|
||||||
"CSRF": csrfToken(r),
|
"CSRF": csrfToken(r),
|
||||||
"ServerVersion": Version,
|
"ServerVersion": Version,
|
||||||
"Outdated": a.Version != "" && a.Version != Version,
|
"Outdated": a.Version != "" && a.Version != Version,
|
||||||
"Updated": r.URL.Query().Get("updated") == "1",
|
"Updated": r.URL.Query().Get("updated") == "1",
|
||||||
|
"UpdateMsg": r.URL.Query().Get("umsg"),
|
||||||
|
"UpdateErr": r.URL.Query().Get("uperr"),
|
||||||
"UserOK": r.URL.Query().Get("uok"),
|
"UserOK": r.URL.Query().Get("uok"),
|
||||||
"UserErr": r.URL.Query().Get("uerr"),
|
"UserErr": r.URL.Query().Get("uerr"),
|
||||||
|
"Transport": firstNonEmpty(a.Transport, proto.TransportAuto),
|
||||||
|
"OnWS": s.wsAgents.On(a.ID),
|
||||||
|
"ViaDirect": viaDirectOf(&s.viaDirectA, a.ID),
|
||||||
|
"Online": s.online(a),
|
||||||
}
|
}
|
||||||
// The user list lives on the machine, so it is only known while it is online.
|
// The user list lives on the machine, so it is only known while it is online.
|
||||||
if s.online(a) {
|
if s.online(a) {
|
||||||
@@ -217,6 +242,21 @@ func (s *Server) handleAgentEdit(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.render(w, "edit.html", data)
|
s.render(w, "edit.html", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// POST /agents/{id}/transport - how the agent is to reach the server: auto,
|
||||||
|
// ws or lp. A connected agent picks it up within a poll round.
|
||||||
|
func (s *Server) handleAgentTransport(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !checkCSRF(r) {
|
||||||
|
http.Error(w, "bad csrf", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, t := r.PathValue("id"), r.FormValue("transport")
|
||||||
|
switch t {
|
||||||
|
case proto.TransportAuto, proto.TransportWS, proto.TransportLP:
|
||||||
|
_ = s.db.SetAgentTransport(id, t)
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/agents/"+id+"/edit", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
// usersTaskTimeout bounds how long the edit page waits for the agent.
|
// usersTaskTimeout bounds how long the edit page waits for the agent.
|
||||||
const usersTaskTimeout = 8 * time.Second
|
const usersTaskTimeout = 8 * time.Second
|
||||||
|
|
||||||
@@ -340,17 +380,28 @@ func (s *Server) handleAgentUpdate(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
back := func(q url.Values) { http.Redirect(w, r, "/agents/"+id+"/edit?"+q.Encode(), http.StatusSeeOther) }
|
||||||
if !s.online(a) {
|
if !s.online(a) {
|
||||||
http.Redirect(w, r, "/agents/"+id+"/edit?updated=0", http.StatusSeeOther)
|
back(url.Values{"uperr": {"машина не в сети"}})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
taskID, ch := s.hub.CreateTask(id, proto.TaskSysUpdate, nil)
|
if admin := s.currentAdmin(r); admin != nil {
|
||||||
defer s.hub.DiscardTask(taskID)
|
s.db.Audit(admin.ID, id, "update:"+a.Version+" -> "+Version)
|
||||||
select {
|
}
|
||||||
case <-ch:
|
// The download can take a while on a slow link; under a proxy's timeout.
|
||||||
case <-time.After(30 * time.Second):
|
res, err := s.waitTask(r.Context(), id, proto.TaskSysUpdate, nil, 80*time.Second)
|
||||||
|
switch {
|
||||||
|
case err != nil:
|
||||||
|
back(url.Values{"uperr": {"агент не ответил: " + err.Error()}})
|
||||||
|
case !res.OK:
|
||||||
|
back(url.Values{"uperr": {res.Error}})
|
||||||
|
default:
|
||||||
|
var d struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(res.Data, &d)
|
||||||
|
back(url.Values{"updated": {"1"}, "umsg": {d.Status}})
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, "/agents/"+id+"/edit?updated=1", http.StatusSeeOther)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleAgentRename(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleAgentRename(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -370,7 +421,15 @@ func (s *Server) handleAgentRename(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
admins, _ := s.db.ListAdmins()
|
admins, _ := s.db.ListAdmins()
|
||||||
|
keyCounts, passOff, agentCounts := map[int64]int{}, map[int64]bool{}, map[int64]int{}
|
||||||
|
for _, a := range admins {
|
||||||
|
keyCounts[a.ID] = s.db.CountAdminKeys(a.ID)
|
||||||
|
passOff[a.ID] = s.db.PasswordOff(a.ID)
|
||||||
|
agentCounts[a.ID] = len(s.db.UserAgents(a.ID))
|
||||||
|
}
|
||||||
|
allAgents, _ := s.db.ListAgents()
|
||||||
host := s.db.HostAddress()
|
host := s.db.HostAddress()
|
||||||
|
direct := s.db.DirectAddress()
|
||||||
secret := s.db.EnrollSecret()
|
secret := s.db.EnrollSecret()
|
||||||
code := s.db.InstallCode()
|
code := s.db.InstallCode()
|
||||||
ufwInstalled, ufwActive, ufwRunnable := s.ufw.status()
|
ufwInstalled, ufwActive, ufwRunnable := s.ufw.status()
|
||||||
@@ -378,8 +437,17 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.render(w, "settings.html", map[string]any{
|
s.render(w, "settings.html", map[string]any{
|
||||||
"Admin": s.currentAdmin(r),
|
"Admin": s.currentAdmin(r),
|
||||||
"Host": host,
|
"Host": host,
|
||||||
|
"Direct": direct,
|
||||||
|
"DirectShort": fmt.Sprintf("wget -qO- %s/i/%s | sudo sh", direct, code),
|
||||||
|
"DirectCurl": fmt.Sprintf("curl -fsSL %s/install.sh | sudo HOST=%s FALLBACK_HOST=%s ENROLL_SECRET=%s sh", direct, direct, host, secret),
|
||||||
"Secret": secret,
|
"Secret": secret,
|
||||||
|
"CodeLen": s.db.InstallCodeLen(),
|
||||||
"Admins": admins,
|
"Admins": admins,
|
||||||
|
"KeyCounts": keyCounts,
|
||||||
|
"AgentCounts": agentCounts,
|
||||||
|
"AllAgents": allAgents,
|
||||||
|
"Me": s.currentAdmin(r),
|
||||||
|
"PassOff": passOff,
|
||||||
"CSRF": csrfToken(r),
|
"CSRF": csrfToken(r),
|
||||||
"InstallShort": fmt.Sprintf("wget -qO- %s/i/%s | sudo sh", host, code),
|
"InstallShort": fmt.Sprintf("wget -qO- %s/i/%s | sudo sh", host, code),
|
||||||
"InstallCurl": fmt.Sprintf("curl -fsSL %s/install.sh | sudo HOST=%s ENROLL_SECRET=%s sh", host, host, secret),
|
"InstallCurl": fmt.Sprintf("curl -fsSL %s/install.sh | sudo HOST=%s ENROLL_SECRET=%s sh", host, host, secret),
|
||||||
@@ -397,6 +465,7 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
"TunnelCustom": s.db.TunnelPort() > 0,
|
"TunnelCustom": s.db.TunnelPort() > 0,
|
||||||
"Error": map[string]string{
|
"Error": map[string]string{
|
||||||
"tunnel_host": "Некорректный хост туннеля: нужен IP или имя без схемы и порта.",
|
"tunnel_host": "Некорректный хост туннеля: нужен IP или имя без схемы и порта.",
|
||||||
|
"host": "Некорректный адрес: нужен URL вида https://panel.example.com, без пути.",
|
||||||
"tunnel_port": "Некорректный порт туннеля.",
|
"tunnel_port": "Некорректный порт туннеля.",
|
||||||
"ufw_port": "Некорректное правило ufw: нужен порт 1-65535 и протокол tcp или udp.",
|
"ufw_port": "Некорректное правило ufw: нужен порт 1-65535 и протокол tcp или udp.",
|
||||||
"ufw_off": "Правило ufw не добавлено: ufw выключен или интеграция с ним отключена.",
|
"ufw_off": "Правило ufw не добавлено: ufw выключен или интеграция с ним отключена.",
|
||||||
@@ -470,9 +539,20 @@ func (s *Server) handleSetHost(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
host := strings.TrimRight(strings.TrimSpace(r.FormValue("host")), "/")
|
host := strings.TrimRight(strings.TrimSpace(r.FormValue("host")), "/")
|
||||||
|
direct := strings.TrimRight(strings.TrimSpace(r.FormValue("direct")), "/")
|
||||||
|
for _, v := range []string{host, direct} {
|
||||||
|
if v == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if u, err := url.Parse(v); err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || (u.Path != "" && u.Path != "/") {
|
||||||
|
http.Redirect(w, r, "/settings?err=host", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
if host != "" {
|
if host != "" {
|
||||||
_ = s.db.SetConfig(cfgHostAddress, host)
|
_ = s.db.SetConfig(cfgHostAddress, host)
|
||||||
}
|
}
|
||||||
|
_ = s.db.SetConfig(cfgDirectAddr, direct)
|
||||||
http.Redirect(w, r, "/settings", http.StatusSeeOther)
|
http.Redirect(w, r, "/settings", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -483,7 +563,22 @@ func (s *Server) handleRotateSecret(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
_ = s.db.SetConfig(cfgEnrollSecret, randToken())
|
_ = s.db.SetConfig(cfgEnrollSecret, randToken())
|
||||||
// Rotate the short install code too so old /i/<code> links stop working.
|
// Rotate the short install code too so old /i/<code> links stop working.
|
||||||
_ = s.db.SetConfig(cfgInstallCode, shortCode())
|
_ = s.db.SetConfig(cfgInstallCode, shortCode(s.db.InstallCodeLen()))
|
||||||
|
http.Redirect(w, r, "/settings", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /settings/install/code - a new install code, of the length given:
|
||||||
|
// the old /i/<code> link stops working.
|
||||||
|
func (s *Server) handleInstallCode(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !checkCSRF(r) {
|
||||||
|
http.Error(w, "bad csrf", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n, err := strconv.Atoi(r.FormValue("len")); err == nil {
|
||||||
|
n = min(installCodeMax, max(installCodeMin, n))
|
||||||
|
_ = s.db.SetConfig(cfgInstallLen, strconv.Itoa(n))
|
||||||
|
}
|
||||||
|
_ = s.db.SetConfig(cfgInstallCode, shortCode(s.db.InstallCodeLen()))
|
||||||
http.Redirect(w, r, "/settings", http.StatusSeeOther)
|
http.Redirect(w, r, "/settings", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -494,12 +589,21 @@ func (s *Server) handleAddAdmin(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
username := strings.TrimSpace(r.FormValue("username"))
|
username := strings.TrimSpace(r.FormValue("username"))
|
||||||
pw := r.FormValue("password")
|
pw := r.FormValue("password")
|
||||||
|
role := RoleAdmin
|
||||||
|
if r.FormValue("role") == RoleUser {
|
||||||
|
role = RoleUser
|
||||||
|
}
|
||||||
if username != "" && len(pw) >= 8 {
|
if username != "" && len(pw) >= 8 {
|
||||||
if hash, err := hashPassword(pw); err == nil {
|
if hash, err := hashPassword(pw); err == nil {
|
||||||
_ = s.db.CreateAdmin(username, hash)
|
if id, err := s.db.CreateUser(username, hash, role); err == nil && role == RoleUser {
|
||||||
|
_ = s.db.SetUserAgents(id, r.Form["agents"])
|
||||||
|
}
|
||||||
|
if me := s.currentAdmin(r); me != nil {
|
||||||
|
s.db.Audit(me.ID, "", "user_add:"+username+":"+role)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, "/settings", http.StatusSeeOther)
|
}
|
||||||
|
http.Redirect(w, r, "/settings#users", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleDeleteAdmin(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleDeleteAdmin(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -509,7 +613,7 @@ func (s *Server) handleDeleteAdmin(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
id, _ := strconv.ParseInt(r.FormValue("id"), 10, 64)
|
id, _ := strconv.ParseInt(r.FormValue("id"), 10, 64)
|
||||||
// Never delete the last admin.
|
// Never delete the last admin.
|
||||||
if n, _ := s.db.CountAdmins(); n > 1 {
|
if u, _ := s.db.AdminByID(id); u != nil && (!u.IsAdmin() || s.db.CountRole(RoleAdmin) > 1) {
|
||||||
_ = s.db.DeleteAdmin(id)
|
_ = s.db.DeleteAdmin(id)
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, "/settings", http.StatusSeeOther)
|
http.Redirect(w, r, "/settings", http.StatusSeeOther)
|
||||||
@@ -562,7 +666,7 @@ func (s *Server) handleUfwRevoke(w http.ResponseWriter, r *http.Request) {
|
|||||||
// handleInstallScript serves the generic installer; the secret is NOT baked in,
|
// handleInstallScript serves the generic installer; the secret is NOT baked in,
|
||||||
// so it must be supplied via env or the interactive prompt.
|
// so it must be supplied via env or the interactive prompt.
|
||||||
func (s *Server) handleInstallScript(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleInstallScript(w http.ResponseWriter, r *http.Request) {
|
||||||
s.renderInstall(w, "")
|
s.renderInstall(w, r, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleShortInstall serves the installer behind the short /i/<code> link with
|
// handleShortInstall serves the installer behind the short /i/<code> link with
|
||||||
@@ -570,20 +674,48 @@ func (s *Server) handleInstallScript(w http.ResponseWriter, r *http.Request) {
|
|||||||
//
|
//
|
||||||
// wget -qO- https://host/i/<code> | sudo sh
|
// wget -qO- https://host/i/<code> | sudo sh
|
||||||
func (s *Server) handleShortInstall(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleShortInstall(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// A short code can be guessed: wrong ones count against the address
|
||||||
|
// like wrong passwords do.
|
||||||
|
ip := clientIP(r)
|
||||||
|
if s.limiter.blocked(ip) {
|
||||||
|
http.Error(w, "too many attempts", http.StatusTooManyRequests)
|
||||||
|
return
|
||||||
|
}
|
||||||
code := r.PathValue("code")
|
code := r.PathValue("code")
|
||||||
want := s.db.InstallCode()
|
want := s.db.InstallCode()
|
||||||
if want == "" || !constantTimeEqual(code, want) {
|
if want == "" || !constantTimeEqual(code, want) {
|
||||||
|
s.limiter.fin(ip, false)
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.renderInstall(w, s.db.EnrollSecret())
|
s.renderInstall(w, r, s.db.EnrollSecret())
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderInstall writes install.sh with the current host and an optional secret.
|
// viaDirect reports whether the request came to the direct address.
|
||||||
func (s *Server) renderInstall(w http.ResponseWriter, secret string) {
|
func (s *Server) viaDirect(r *http.Request) bool {
|
||||||
|
d := directHost(s.db.DirectAddress())
|
||||||
|
if d == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
host := r.Host
|
||||||
|
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||||
|
host = h
|
||||||
|
}
|
||||||
|
return strings.EqualFold(host, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderInstall writes install.sh with the server's addresses and an
|
||||||
|
// optional secret. Fetched from the direct address, it makes that the
|
||||||
|
// agent's address and the main one its fallback; otherwise the other way.
|
||||||
|
func (s *Server) renderInstall(w http.ResponseWriter, r *http.Request, secret string) {
|
||||||
|
host, fallback := s.db.HostAddress(), s.db.DirectAddress()
|
||||||
|
if s.viaDirect(r) {
|
||||||
|
host, fallback = fallback, host
|
||||||
|
}
|
||||||
w.Header().Set("Content-Type", "text/x-shellscript; charset=utf-8")
|
w.Header().Set("Content-Type", "text/x-shellscript; charset=utf-8")
|
||||||
if err := s.installTmpl.Execute(w, map[string]any{
|
if err := s.installTmpl.Execute(w, map[string]any{
|
||||||
"Host": s.db.HostAddress(),
|
"Host": host,
|
||||||
|
"Fallback": fallback,
|
||||||
"Secret": secret,
|
"Secret": secret,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Printf("install.sh render: %v", err)
|
log.Printf("install.sh render: %v", err)
|
||||||
@@ -596,3 +728,11 @@ func (s *Server) downloadHandler() http.Handler {
|
|||||||
// install one-liner works before the machine is enrolled.
|
// install one-liner works before the machine is enrolled.
|
||||||
return http.FileServer(http.Dir(s.distDir))
|
return http.FileServer(http.Dir(s.distDir))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// viaDirectOf tells whether the agent last reached the server at the direct
|
||||||
|
// address.
|
||||||
|
func viaDirectOf(m *sync.Map, id string) bool {
|
||||||
|
v, _ := m.Load(id)
|
||||||
|
b, _ := v.(bool)
|
||||||
|
return b
|
||||||
|
}
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||||
@@ -22,6 +20,15 @@ var fsOps = map[string]string{
|
|||||||
"unzip": proto.TaskFSUnzip,
|
"unzip": proto.TaskFSUnzip,
|
||||||
"zip": proto.TaskFSZip,
|
"zip": proto.TaskFSZip,
|
||||||
"move": proto.TaskFSMove,
|
"move": proto.TaskFSMove,
|
||||||
|
"stat": proto.TaskFSStat,
|
||||||
|
"chmod": proto.TaskFSChmod,
|
||||||
|
"copy": proto.TaskFSCopy,
|
||||||
|
"db.tables": proto.TaskDBTables,
|
||||||
|
"db.rows": proto.TaskDBRows,
|
||||||
|
"db.set": proto.TaskDBSet,
|
||||||
|
"db.add": proto.TaskDBAdd,
|
||||||
|
"db.del": proto.TaskDBDel,
|
||||||
|
"db.sql": proto.TaskDBSQL,
|
||||||
}
|
}
|
||||||
|
|
||||||
// fsTaskTimeout bounds how long we wait for an agent to answer a file op.
|
// fsTaskTimeout bounds how long we wait for an agent to answer a file op.
|
||||||
@@ -63,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)
|
||||||
|
|
||||||
@@ -73,44 +100,3 @@ func (s *Server) handleFSOp(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, proto.TaskResult{Error: "таймаут ожидания агента"})
|
writeJSON(w, proto.TaskResult{Error: "таймаут ожидания агента"})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/panel/agents/{id}/fs/download?path=... - stream a file as a download.
|
|
||||||
func (s *Server) handleFSDownload(w http.ResponseWriter, r *http.Request) {
|
|
||||||
agentID := r.PathValue("id")
|
|
||||||
p := r.URL.Query().Get("path")
|
|
||||||
a, err := s.db.GetAgent(agentID)
|
|
||||||
if err != nil || a == nil {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !s.online(a) {
|
|
||||||
http.Error(w, "машина офлайн", http.StatusServiceUnavailable)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
args, _ := json.Marshal(proto.FSPathArgs{Path: p})
|
|
||||||
taskID, ch := s.hub.CreateTask(agentID, proto.TaskFSDownload, args)
|
|
||||||
defer s.hub.DiscardTask(taskID)
|
|
||||||
|
|
||||||
select {
|
|
||||||
case res := <-ch:
|
|
||||||
if !res.OK {
|
|
||||||
http.Error(w, res.Error, http.StatusBadGateway)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var rr proto.FSReadResult
|
|
||||||
if err := json.Unmarshal(res.Data, &rr); err != nil {
|
|
||||||
http.Error(w, "bad result", http.StatusBadGateway)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
data, _ := base64.StdEncoding.DecodeString(rr.Content)
|
|
||||||
name := path.Base(p)
|
|
||||||
w.Header().Set("Content-Type", "application/octet-stream")
|
|
||||||
w.Header().Set("Content-Disposition", "attachment; filename=\""+name+"\"")
|
|
||||||
if rr.Truncated {
|
|
||||||
w.Header().Set("X-Truncated", "1")
|
|
||||||
}
|
|
||||||
_, _ = w.Write(data)
|
|
||||||
case <-time.After(fsTaskTimeout):
|
|
||||||
http.Error(w, "таймаут ожидания агента", http.StatusGatewayTimeout)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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())
|
||||||
@@ -49,6 +87,9 @@ type panelInMsg struct {
|
|||||||
D string `json:"d,omitempty"`
|
D string `json:"d,omitempty"`
|
||||||
Cols uint16 `json:"cols,omitempty"`
|
Cols uint16 `json:"cols,omitempty"`
|
||||||
Rows uint16 `json:"rows,omitempty"`
|
Rows uint16 `json:"rows,omitempty"`
|
||||||
|
// Seq numbers the messages of a session from 1; 0 from a page that
|
||||||
|
// predates it.
|
||||||
|
Seq uint64 `json:"seq,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/panel/term/{sid}/in - send keystrokes/resize to the agent PTY.
|
// POST /api/panel/term/{sid}/in - send keystrokes/resize to the agent PTY.
|
||||||
@@ -63,13 +104,86 @@ 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) {
|
||||||
|
return
|
||||||
|
}
|
||||||
switch m.T {
|
switch m.T {
|
||||||
case "i":
|
case "i":
|
||||||
sess.PushIn(proto.InFrame{Type: proto.FrameStdin, Data: m.D})
|
sess.PushIn(proto.InFrame{Type: proto.FrameStdin, Data: m.D})
|
||||||
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.
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ type Session struct {
|
|||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
inBuf []proto.InFrame
|
inBuf []proto.InFrame
|
||||||
inSignal chan struct{} // size-1; pulsed when inBuf gains data
|
inSignal chan struct{} // size-1; pulsed when inBuf gains data
|
||||||
|
inSeq uint64 // number of the last browser message taken
|
||||||
|
|
||||||
out chan proto.OutMessage // agent -> panel
|
out chan proto.OutMessage // agent -> panel
|
||||||
|
|
||||||
@@ -99,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)
|
||||||
@@ -250,6 +258,19 @@ func (s *Session) close() { s.closeOnce.Do(func() { close(s.closed) }) }
|
|||||||
// Done returns a channel closed when the session ends.
|
// Done returns a channel closed when the session ends.
|
||||||
func (s *Session) Done() <-chan struct{} { return s.closed }
|
func (s *Session) Done() <-chan struct{} { return s.closed }
|
||||||
|
|
||||||
|
// TakeSeq reports whether the browser message numbered seq is new, and takes
|
||||||
|
// note of it. The browser sends them one at a time in rising order and sends
|
||||||
|
// one again when it got no answer, so a number not above the last is a repeat.
|
||||||
|
func (s *Session) TakeSeq(seq uint64) bool {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if seq <= s.inSeq {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
s.inSeq = seq
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// PushIn appends a downstream frame (panel -> agent) and wakes the agent poll.
|
// PushIn appends a downstream frame (panel -> agent) and wakes the agent poll.
|
||||||
func (s *Session) PushIn(f proto.InFrame) {
|
func (s *Session) PushIn(f proto.InFrame) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
@@ -261,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)
|
||||||
@@ -298,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})
|
||||||
|
}
|
||||||
+134
-14
@@ -1,7 +1,9 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
"embed"
|
"embed"
|
||||||
|
"encoding/hex"
|
||||||
"html/template"
|
"html/template"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
@@ -56,6 +58,13 @@ type Server struct {
|
|||||||
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
|
||||||
|
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.
|
||||||
@@ -63,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
|
||||||
}
|
}
|
||||||
@@ -89,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
|
||||||
@@ -123,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
|
||||||
@@ -183,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/", http.FileServer(http.FS(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)
|
||||||
@@ -198,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))
|
||||||
@@ -209,26 +235,48 @@ 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).
|
||||||
mux.Handle("GET /api/panel/agents/{id}/fs/download", s.requireAdmin(s.handleFSDownload))
|
mux.Handle("GET /api/panel/agents/{id}/fs/download", s.requireAdmin(s.handleFSDownload))
|
||||||
|
mux.Handle("POST /api/panel/agents/{id}/fs/upload", s.requireAdmin(s.handleFSUploadStart))
|
||||||
|
mux.Handle("PUT /api/panel/fs/xfer/{xid}/{i}", s.requireAdmin(s.handleXferPut))
|
||||||
|
mux.Handle("POST /api/panel/fs/xfer/{xid}/cancel", s.requireAdmin(s.handleXferCancel))
|
||||||
|
mux.Handle("GET /api/panel/fs/xfer/{xid}/result", s.requireAdmin(s.handleXferResult))
|
||||||
|
mux.Handle("POST /api/panel/agents/{id}/fs/ship", s.requireAdmin(s.handleFSShip))
|
||||||
|
mux.Handle("GET /api/panel/fs/job/{jid}", s.requireAdmin(s.handleShipJob))
|
||||||
|
mux.Handle("POST /api/panel/fs/job/{jid}/cancel", s.requireAdmin(s.handleShipCancel))
|
||||||
mux.Handle("POST /api/panel/agents/{id}/fs/{op}", s.requireAdmin(s.handleFSOp))
|
mux.Handle("POST /api/panel/agents/{id}/fs/{op}", s.requireAdmin(s.handleFSOp))
|
||||||
|
|
||||||
// Machine status (admin).
|
// Machine status (admin).
|
||||||
@@ -248,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)
|
||||||
@@ -257,6 +306,11 @@ func (s *Server) Handler() http.Handler {
|
|||||||
mux.HandleFunc("POST /api/agent/speed/up", s.handleSpeedUp)
|
mux.HandleFunc("POST /api/agent/speed/up", s.handleSpeedUp)
|
||||||
mux.HandleFunc("GET /api/agent/speed/relay/down", s.handleSpeedRelayDown)
|
mux.HandleFunc("GET /api/agent/speed/relay/down", s.handleSpeedRelayDown)
|
||||||
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("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)
|
||||||
}
|
}
|
||||||
@@ -273,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)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -326,3 +410,39 @@ func clientIP(r *http.Request) string {
|
|||||||
}
|
}
|
||||||
return host
|
return host
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// assetHashes hashes every static file, for asset URLs and ETags.
|
||||||
|
func assetHashes(fsys fs.FS) map[string]string {
|
||||||
|
out := map[string]string{}
|
||||||
|
_ = fs.WalkDir(fsys, ".", func(p string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil || d.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if data, err := fs.ReadFile(fsys, p); err == nil {
|
||||||
|
sum := sha256.Sum256(data)
|
||||||
|
out[p] = hex.EncodeToString(sum[:8])
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// staticHandler serves the embedded assets. Pages ask for them by URLs that
|
||||||
|
// carry the content hash (?v=): such a file never changes and is kept for a
|
||||||
|
// year, by the browser and by a proxy in front such as Cloudflare. Asked for
|
||||||
|
// without it, a file is checked every time against its ETag.
|
||||||
|
func staticHandler(fsys fs.FS, hashes map[string]string) http.Handler {
|
||||||
|
files := http.FileServer(http.FS(fsys))
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h := hashes[strings.TrimPrefix(r.URL.Path, "/")]
|
||||||
|
if h != "" {
|
||||||
|
w.Header().Set("ETag", `"`+h+`"`)
|
||||||
|
}
|
||||||
|
if v := r.URL.Query().Get("v"); h != "" && v == h {
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||||
|
} else {
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
}
|
||||||
|
files.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,418 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sending a file or folder to another machine. The sending agent packs it as
|
||||||
|
// a tar stream; to another agent the stream goes in pieces through a relay
|
||||||
|
// here, a few pieces held at most, and the receiving agent unpacks it as it
|
||||||
|
// comes. To an SSH host the sending agent writes it there itself. The panel
|
||||||
|
// follows a job: how far the sender is, and the result.
|
||||||
|
|
||||||
|
// relay carries one stream between two agents, in numbered pieces.
|
||||||
|
type relay struct {
|
||||||
|
id, from, to string
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
changed chan struct{}
|
||||||
|
chunks map[int][]byte
|
||||||
|
low int // pieces below it were taken
|
||||||
|
end int // the last piece's number, -1 until it came
|
||||||
|
err string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *relay) notify() {
|
||||||
|
close(x.changed)
|
||||||
|
x.changed = make(chan struct{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// await waits until ready() holds and returns true with x.mu held.
|
||||||
|
func (x *relay) await(ctx context.Context, d time.Duration, ready func() bool) bool {
|
||||||
|
t := time.NewTimer(d)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
x.mu.Lock()
|
||||||
|
if ready() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
ch := x.changed
|
||||||
|
x.mu.Unlock()
|
||||||
|
select {
|
||||||
|
case <-ch:
|
||||||
|
case <-t.C:
|
||||||
|
return false
|
||||||
|
case <-ctx.Done():
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *relay) fail(msg string) {
|
||||||
|
x.mu.Lock()
|
||||||
|
if x.err == "" {
|
||||||
|
x.err = msg
|
||||||
|
x.chunks = nil
|
||||||
|
x.notify()
|
||||||
|
}
|
||||||
|
x.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
type shipJob struct {
|
||||||
|
id string
|
||||||
|
mu sync.Mutex
|
||||||
|
changed chan struct{}
|
||||||
|
ver int
|
||||||
|
prog proto.ShipProgress
|
||||||
|
done bool
|
||||||
|
res proto.TaskResult
|
||||||
|
cancel context.CancelFunc
|
||||||
|
stopped bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *shipJob) update(fn func()) {
|
||||||
|
j.mu.Lock()
|
||||||
|
fn()
|
||||||
|
j.ver++
|
||||||
|
close(j.changed)
|
||||||
|
j.changed = make(chan struct{})
|
||||||
|
j.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
type shipping struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
relays map[string]*relay
|
||||||
|
jobs map[string]*shipJob
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *shipping) relay(id string) *relay {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
return c.relays[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *shipping) job(id string) *shipJob {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
return c.jobs[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
// shipReq is what the panel asks for: Src on this agent to Dir/Name on agent
|
||||||
|
// To, or on the SSH host.
|
||||||
|
type shipReq struct {
|
||||||
|
Src string `json:"src"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Dir string `json:"dir"`
|
||||||
|
Conflict string `json:"conflict"`
|
||||||
|
Move bool `json:"move"`
|
||||||
|
To string `json:"to"`
|
||||||
|
SSH *proto.SSHTarget `json:"ssh"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/panel/agents/{id}/fs/ship - start sending; the answer names the
|
||||||
|
// job to follow.
|
||||||
|
func (s *Server) handleFSShip(w http.ResponseWriter, r *http.Request) {
|
||||||
|
from := r.PathValue("id")
|
||||||
|
var q shipReq
|
||||||
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&q); err != nil {
|
||||||
|
writeJSON(w, proto.TaskResult{Error: "bad request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q.Name, q.Dir = strings.TrimSpace(q.Name), strings.TrimSpace(q.Dir)
|
||||||
|
if q.Src == "" || q.Name == "" || strings.Contains(q.Name, "/") || q.Dir == "" {
|
||||||
|
writeJSON(w, proto.TaskResult{Error: "не указано, что и куда"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.fsAgent(w, from, true) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
where := ""
|
||||||
|
if q.SSH != nil {
|
||||||
|
t := q.SSH
|
||||||
|
t.Host, t.User = strings.TrimSpace(t.Host), strings.TrimSpace(t.User)
|
||||||
|
if t.Port == 0 {
|
||||||
|
t.Port = 22
|
||||||
|
}
|
||||||
|
if t.Host == "" || t.User == "" || t.Port < 1 || t.Port > 65535 {
|
||||||
|
writeJSON(w, proto.TaskResult{Error: "нужны хост, порт и пользователь"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
where = "ssh:" + t.User + "@" + t.Host + ":" + strconv.Itoa(t.Port)
|
||||||
|
} else {
|
||||||
|
if q.To == "" {
|
||||||
|
writeJSON(w, proto.TaskResult{Error: "не выбрана машина"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.db.CanUseAgent(s.currentAdmin(r), q.To) {
|
||||||
|
writeJSON(w, proto.TaskResult{Error: "нет доступа к той машине"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.fsAgent(w, q.To, true) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
where = "agent:" + q.To
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), xferTimeout)
|
||||||
|
j := &shipJob{id: randToken()[:24], changed: make(chan struct{}), cancel: cancel}
|
||||||
|
s.ship.mu.Lock()
|
||||||
|
if s.ship.jobs == nil {
|
||||||
|
s.ship.jobs, s.ship.relays = map[string]*shipJob{}, map[string]*relay{}
|
||||||
|
}
|
||||||
|
s.ship.jobs[j.id] = j
|
||||||
|
s.ship.mu.Unlock()
|
||||||
|
if admin := s.currentAdmin(r); admin != nil {
|
||||||
|
op := "copy"
|
||||||
|
if q.Move {
|
||||||
|
op = "move"
|
||||||
|
}
|
||||||
|
s.db.Audit(admin.ID, from, "fs.ship."+op+":"+q.Src+" -> "+where+":"+strings.TrimSuffix(q.Dir, "/")+"/"+q.Name)
|
||||||
|
}
|
||||||
|
go s.runShip(ctx, j, from, q)
|
||||||
|
data, _ := json.Marshal(map[string]string{"job": j.id})
|
||||||
|
writeJSON(w, proto.TaskResult{OK: true, Data: data})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) runShip(ctx context.Context, j *shipJob, from string, q shipReq) {
|
||||||
|
defer j.cancel()
|
||||||
|
res := s.ship1(ctx, j, from, q)
|
||||||
|
if !res.OK && j.stopped {
|
||||||
|
res.Error = "отменено"
|
||||||
|
}
|
||||||
|
j.update(func() { j.done, j.res = true, res })
|
||||||
|
// The result waits a while for the panel to come for it.
|
||||||
|
time.AfterFunc(xferIdle, func() {
|
||||||
|
s.ship.mu.Lock()
|
||||||
|
delete(s.ship.jobs, j.id)
|
||||||
|
s.ship.mu.Unlock()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func oldAgent(res proto.TaskResult) proto.TaskResult {
|
||||||
|
if unknownOp(res) {
|
||||||
|
res.Error = "агент не умеет отправлять на другие машины - обновите его"
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) ship1(ctx context.Context, j *shipJob, from string, q shipReq) proto.TaskResult {
|
||||||
|
args := proto.FSShipArgs{Src: q.Src, Name: q.Name, Dir: q.Dir, Conflict: q.Conflict, Job: j.id}
|
||||||
|
if q.SSH != nil {
|
||||||
|
args.SSH, args.Move = q.SSH, q.Move
|
||||||
|
res, err := s.waitTask(ctx, from, proto.TaskFSShipOut, args, xferTimeout)
|
||||||
|
if err != nil {
|
||||||
|
return proto.TaskResult{Error: err.Error()}
|
||||||
|
}
|
||||||
|
return oldAgent(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
x := &relay{id: randToken()[:24], from: from, to: q.To, changed: make(chan struct{}), chunks: map[int][]byte{}, end: -1}
|
||||||
|
s.ship.mu.Lock()
|
||||||
|
s.ship.relays[x.id] = x
|
||||||
|
s.ship.mu.Unlock()
|
||||||
|
defer func() {
|
||||||
|
s.ship.mu.Lock()
|
||||||
|
delete(s.ship.relays, x.id)
|
||||||
|
s.ship.mu.Unlock()
|
||||||
|
}()
|
||||||
|
go func() { <-ctx.Done(); x.fail("отменено") }()
|
||||||
|
args.Relay, args.Chunk = x.id, xferChunk
|
||||||
|
|
||||||
|
var in proto.TaskResult
|
||||||
|
inDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(inDone)
|
||||||
|
res, err := s.waitTask(ctx, q.To, proto.TaskFSShipIn, args, xferTimeout)
|
||||||
|
if err != nil {
|
||||||
|
res = proto.TaskResult{Error: err.Error()}
|
||||||
|
}
|
||||||
|
in = oldAgent(res)
|
||||||
|
if !in.OK {
|
||||||
|
x.fail(in.Error)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
out, err := s.waitTask(ctx, from, proto.TaskFSShipOut, args, xferTimeout)
|
||||||
|
if err != nil {
|
||||||
|
out = proto.TaskResult{Error: err.Error()}
|
||||||
|
}
|
||||||
|
out = oldAgent(out)
|
||||||
|
if !out.OK {
|
||||||
|
x.fail(out.Error)
|
||||||
|
}
|
||||||
|
<-inDone
|
||||||
|
if !in.OK {
|
||||||
|
return proto.TaskResult{Error: "на той машине: " + in.Error}
|
||||||
|
}
|
||||||
|
if !out.OK {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
if q.Move {
|
||||||
|
res, err := s.waitTask(ctx, from, proto.TaskFSDelTree, proto.FSPathArgs{Path: q.Src}, fsTaskTimeout)
|
||||||
|
if err == nil && !res.OK {
|
||||||
|
err = errText(res.Error)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return proto.TaskResult{Error: "отправлено, но здесь не удалилось: " + err.Error()}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The receiver's count: what it put down and what it kept as it was.
|
||||||
|
return in
|
||||||
|
}
|
||||||
|
|
||||||
|
type errText string
|
||||||
|
|
||||||
|
func (e errText) Error() string { return string(e) }
|
||||||
|
|
||||||
|
// GET /api/panel/fs/job/{jid}?v=N - the job's state once it differs from
|
||||||
|
// version N, or after a while anyway.
|
||||||
|
func (s *Server) handleShipJob(w http.ResponseWriter, r *http.Request) {
|
||||||
|
j := s.ship.job(r.PathValue("jid"))
|
||||||
|
if j == nil {
|
||||||
|
writeJSON(w, map[string]any{"done": true, "result": proto.TaskResult{Error: "задание не найдено"}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen, _ := strconv.Atoi(r.URL.Query().Get("v"))
|
||||||
|
t := time.NewTimer(25 * time.Second)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
j.mu.Lock()
|
||||||
|
if j.ver != seen || j.done {
|
||||||
|
out := map[string]any{"v": j.ver, "progress": j.prog, "done": j.done}
|
||||||
|
if j.done {
|
||||||
|
out["result"] = j.res
|
||||||
|
}
|
||||||
|
j.mu.Unlock()
|
||||||
|
writeJSON(w, out)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ch := j.changed
|
||||||
|
j.mu.Unlock()
|
||||||
|
select {
|
||||||
|
case <-ch:
|
||||||
|
case <-t.C:
|
||||||
|
seen = -1 // answer with what there is
|
||||||
|
case <-r.Context().Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/panel/fs/job/{jid}/cancel
|
||||||
|
func (s *Server) handleShipCancel(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if j := s.ship.job(r.PathValue("jid")); j != nil {
|
||||||
|
j.update(func() { j.stopped = true })
|
||||||
|
j.cancel()
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/agent/fs/job/{jid}/progress - the sender's progress. 410: the
|
||||||
|
// job is over or cancelled, stop.
|
||||||
|
func (s *Server) handleShipProgress(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.authenticateAgent(r) == nil {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
j := s.ship.job(r.PathValue("jid"))
|
||||||
|
var p proto.ShipProgress
|
||||||
|
if j == nil || json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&p) != nil {
|
||||||
|
http.Error(w, "gone", http.StatusGone)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stop := false
|
||||||
|
j.update(func() { j.prog, stop = p, j.stopped || j.done })
|
||||||
|
if stop {
|
||||||
|
http.Error(w, "gone", http.StatusGone)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/agent/fs/relay/{rid}/{i} - piece i of the stream from the
|
||||||
|
// sender, X-Last on the last. 503: no room yet, again; 410: it is over.
|
||||||
|
func (s *Server) handleRelayPost(w http.ResponseWriter, r *http.Request) {
|
||||||
|
a := s.authenticateAgent(r)
|
||||||
|
if a == nil {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
x := s.ship.relay(r.PathValue("rid"))
|
||||||
|
i, err := strconv.Atoi(r.PathValue("i"))
|
||||||
|
if x == nil || x.from != a.ID || err != nil || i < 0 {
|
||||||
|
http.Error(w, "передача не найдена", http.StatusGone)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := readPiece(w, r)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "кусок не дошёл", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !x.await(r.Context(), xferWait, func() bool { return x.err != "" || i < x.low+xferBuffer }) {
|
||||||
|
http.Error(w, "нет места, повторите", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer x.mu.Unlock()
|
||||||
|
if x.err != "" {
|
||||||
|
http.Error(w, x.err, http.StatusGone)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if i >= x.low && x.chunks[i] == nil {
|
||||||
|
x.chunks[i] = data
|
||||||
|
if r.Header.Get("X-Last") != "" {
|
||||||
|
x.end = i
|
||||||
|
}
|
||||||
|
x.notify()
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/agent/fs/relay/{rid}/{i} - piece i for the receiver, X-Last on
|
||||||
|
// the last. Asking for i says every piece below it is written, so those are
|
||||||
|
// let go. 503: not here yet, ask again; 410: it is over.
|
||||||
|
func (s *Server) handleRelayGet(w http.ResponseWriter, r *http.Request) {
|
||||||
|
a := s.authenticateAgent(r)
|
||||||
|
if a == nil {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
x := s.ship.relay(r.PathValue("rid"))
|
||||||
|
i, err := strconv.Atoi(r.PathValue("i"))
|
||||||
|
if x == nil || x.to != a.ID || err != nil || i < 0 {
|
||||||
|
http.Error(w, "передача не найдена", http.StatusGone)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
x.mu.Lock()
|
||||||
|
if i > x.low {
|
||||||
|
for k := x.low; k < i; k++ {
|
||||||
|
delete(x.chunks, k)
|
||||||
|
}
|
||||||
|
x.low = i
|
||||||
|
x.notify()
|
||||||
|
}
|
||||||
|
x.mu.Unlock()
|
||||||
|
if !x.await(r.Context(), xferWait, func() bool { return x.err != "" || x.chunks[i] != nil }) {
|
||||||
|
http.Error(w, "кусок ещё не пришёл", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if x.err != "" {
|
||||||
|
msg := x.err
|
||||||
|
x.mu.Unlock()
|
||||||
|
http.Error(w, msg, http.StatusGone)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, last := x.chunks[i], i == x.end
|
||||||
|
x.mu.Unlock()
|
||||||
|
w.Header().Set("Content-Type", "application/octet-stream")
|
||||||
|
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
|
||||||
|
if last {
|
||||||
|
w.Header().Set("X-Last", "1")
|
||||||
|
}
|
||||||
|
_, _ = w.Write(data)
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
@@ -62,6 +69,12 @@ button, .btn {
|
|||||||
button:hover, .btn:hover { background: var(--accent-hover); text-decoration: none; }
|
button:hover, .btn:hover { background: var(--accent-hover); text-decoration: none; }
|
||||||
.btn.ghost { background: transparent; border: 1px solid var(--border); color: var(--text); }
|
.btn.ghost { background: transparent; border: 1px solid var(--border); color: var(--text); }
|
||||||
.btn.danger { background: var(--danger); }
|
.btn.danger { background: var(--danger); }
|
||||||
|
/* Close buttons: red text and border on the ghost button. */
|
||||||
|
.btn.close, .btn.ghost.close { background: transparent; border: 1px solid rgba(255, 92, 92, .6); color: var(--danger); }
|
||||||
|
.btn.close:hover, .btn.ghost.close:hover { background: #3a1d1d; border-color: var(--danger); }
|
||||||
|
/* A close button that is just a cross: square, the height of the buttons by it. */
|
||||||
|
.btn.close.x { width: 32px; height: 32px; padding: 0; display: inline-flex; align-items: center; justify-content: center; }
|
||||||
|
.btn.small.close.x { width: 28px; height: 28px; }
|
||||||
.btn.small { padding: 6px 10px; font-size: 13px; }
|
.btn.small { padding: 6px 10px; font-size: 13px; }
|
||||||
.btn.icon { width: 30px; height: 30px; padding: 0; display: inline-flex;
|
.btn.icon { width: 30px; height: 30px; padding: 0; display: inline-flex;
|
||||||
align-items: center; justify-content: center; font-size: 14px; line-height: 1; text-decoration: none; }
|
align-items: center; justify-content: center; font-size: 14px; line-height: 1; text-decoration: none; }
|
||||||
@@ -96,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. */
|
||||||
@@ -106,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; }
|
||||||
@@ -124,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,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (C) 2017 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
.CodeMirror{font-family:monospace;height:300px;color:#000;direction:ltr}.CodeMirror-lines{padding:4px 0}.CodeMirror pre.CodeMirror-line,.CodeMirror pre.CodeMirror-line-like{padding:0 4px}.CodeMirror-gutter-filler,.CodeMirror-scrollbar-filler{background-color:#fff}.CodeMirror-gutters{border-right:1px solid #ddd;background-color:#f7f7f7;white-space:nowrap}.CodeMirror-linenumber{padding:0 3px 0 5px;min-width:20px;text-align:right;color:#999;white-space:nowrap}.CodeMirror-guttermarker{color:#000}.CodeMirror-guttermarker-subtle{color:#999}.CodeMirror-cursor{border-left:1px solid #000;border-right:none;width:0}.CodeMirror div.CodeMirror-secondarycursor{border-left:1px solid silver}.cm-fat-cursor .CodeMirror-cursor{width:auto;border:0!important;background:#7e7}.cm-fat-cursor div.CodeMirror-cursors{z-index:1}.cm-fat-cursor .CodeMirror-line::selection,.cm-fat-cursor .CodeMirror-line>span::selection,.cm-fat-cursor .CodeMirror-line>span>span::selection{background:0 0}.cm-fat-cursor .CodeMirror-line::-moz-selection,.cm-fat-cursor .CodeMirror-line>span::-moz-selection,.cm-fat-cursor .CodeMirror-line>span>span::-moz-selection{background:0 0}.cm-fat-cursor{caret-color:transparent}@-moz-keyframes blink{50%{background-color:transparent}}@-webkit-keyframes blink{50%{background-color:transparent}}@keyframes blink{50%{background-color:transparent}}.cm-tab{display:inline-block;text-decoration:inherit}.CodeMirror-rulers{position:absolute;left:0;right:0;top:-50px;bottom:0;overflow:hidden}.CodeMirror-ruler{border-left:1px solid #ccc;top:0;bottom:0;position:absolute}.cm-s-default .cm-header{color:#00f}.cm-s-default .cm-quote{color:#090}.cm-negative{color:#d44}.cm-positive{color:#292}.cm-header,.cm-strong{font-weight:700}.cm-em{font-style:italic}.cm-link{text-decoration:underline}.cm-strikethrough{text-decoration:line-through}.cm-s-default .cm-keyword{color:#708}.cm-s-default .cm-atom{color:#219}.cm-s-default .cm-number{color:#164}.cm-s-default .cm-def{color:#00f}.cm-s-default .cm-variable-2{color:#05a}.cm-s-default .cm-type,.cm-s-default .cm-variable-3{color:#085}.cm-s-default .cm-comment{color:#a50}.cm-s-default .cm-string{color:#a11}.cm-s-default .cm-string-2{color:#f50}.cm-s-default .cm-meta{color:#555}.cm-s-default .cm-qualifier{color:#555}.cm-s-default .cm-builtin{color:#30a}.cm-s-default .cm-bracket{color:#997}.cm-s-default .cm-tag{color:#170}.cm-s-default .cm-attribute{color:#00c}.cm-s-default .cm-hr{color:#999}.cm-s-default .cm-link{color:#00c}.cm-s-default .cm-error{color:red}.cm-invalidchar{color:red}.CodeMirror-composing{border-bottom:2px solid}div.CodeMirror span.CodeMirror-matchingbracket{color:#0b0}div.CodeMirror span.CodeMirror-nonmatchingbracket{color:#a22}.CodeMirror-matchingtag{background:rgba(255,150,0,.3)}.CodeMirror-activeline-background{background:#e8f2ff}.CodeMirror{position:relative;overflow:hidden;background:#fff}.CodeMirror-scroll{overflow:scroll!important;margin-bottom:-50px;margin-right:-50px;padding-bottom:50px;height:100%;outline:0;position:relative;z-index:0}.CodeMirror-sizer{position:relative;border-right:50px solid transparent}.CodeMirror-gutter-filler,.CodeMirror-hscrollbar,.CodeMirror-scrollbar-filler,.CodeMirror-vscrollbar{position:absolute;z-index:6;display:none;outline:0}.CodeMirror-vscrollbar{right:0;top:0;overflow-x:hidden;overflow-y:scroll}.CodeMirror-hscrollbar{bottom:0;left:0;overflow-y:hidden;overflow-x:scroll}.CodeMirror-scrollbar-filler{right:0;bottom:0}.CodeMirror-gutter-filler{left:0;bottom:0}.CodeMirror-gutters{position:absolute;left:0;top:0;min-height:100%;z-index:3}.CodeMirror-gutter{white-space:normal;height:100%;display:inline-block;vertical-align:top;margin-bottom:-50px}.CodeMirror-gutter-wrapper{position:absolute;z-index:4;background:0 0!important;border:none!important}.CodeMirror-gutter-background{position:absolute;top:0;bottom:0;z-index:4}.CodeMirror-gutter-elt{position:absolute;cursor:default;z-index:4}.CodeMirror-gutter-wrapper ::selection{background-color:transparent}.CodeMirror-gutter-wrapper ::-moz-selection{background-color:transparent}.CodeMirror-lines{cursor:text;min-height:1px}.CodeMirror pre.CodeMirror-line,.CodeMirror pre.CodeMirror-line-like{-moz-border-radius:0;-webkit-border-radius:0;border-radius:0;border-width:0;background:0 0;font-family:inherit;font-size:inherit;margin:0;white-space:pre;word-wrap:normal;line-height:inherit;color:inherit;z-index:2;position:relative;overflow:visible;-webkit-tap-highlight-color:transparent;-webkit-font-variant-ligatures:contextual;font-variant-ligatures:contextual}.CodeMirror-wrap pre.CodeMirror-line,.CodeMirror-wrap pre.CodeMirror-line-like{word-wrap:break-word;white-space:pre-wrap;word-break:normal}.CodeMirror-linebackground{position:absolute;left:0;right:0;top:0;bottom:0;z-index:0}.CodeMirror-linewidget{position:relative;z-index:2;padding:.1px}.CodeMirror-rtl pre{direction:rtl}.CodeMirror-code{outline:0}.CodeMirror-gutter,.CodeMirror-gutters,.CodeMirror-linenumber,.CodeMirror-scroll,.CodeMirror-sizer{-moz-box-sizing:contentLine truncated
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/* CodeMirror 5.65.16 (MIT, see LICENSE) with the addons and language modes the file editor uses, joined into one file. */
|
||||||
|
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e=e||self).CodeMirror=t()}(this,function(){"use strict";var e=navigator.userAgent,l=navigator.platform,d=/gecko\/\d/i.test(e),s=/MSIE \d/.test(e),a=/Trident\/(?:[7-9]|\d{2,})\..*rv:(\d+)/.exec(e),u=/Edge\/(\d+)/.exec(e),w=s||a||u,v=w&&(s?document.documentMode||6:+(u||a)[1]),x=!u&&/WebKit\//.test(e),s=x&&/Qt\/\d+\.\d+/.test(e),m=!u&&/Chrome\/(\d+)/.exec(e),V=m&&+m[1],K=/Opera\//.test(e),j=/Apple Computer/.test(navigator.vendor),c=/Mac OS X 1\d\D([8-9]|\d\d)\D/.test(e),X=/PhantomJS/.test(e),Y=j&&(/Mobile\/\w+/.test(e)||2<navigator.maxTouchPoints),$=/Android/.test(e),_=Y||$||/webOS|BlackBerry|Opera Mini|Opera Mobi|IEMobile/i.test(e),C=Y||/Mac/.test(l),q=/\bCrOS\b/.test(e),a=/win/i.test(l),u=K&&e.match(/Version\/(\d*\.\d*)/),Z=((u=u&&Number(u[1]))&&15<=u&&(x=!(K=!1)),C&&(s||K&&(null==u||u<12.11))),Q=d||w&&9<=v;function J(e){return new RegExp("(^|\\s)"+e+"(?:$|\\s)\\s*")}var ee=function(e,t){var n,r=e.className,t=J(t).exec(r);t&&(n=r.slice(t.index+t[0].length),e.className=r.slice(0,t.index)+(n?t[1]+n:""))};function te(e){for(var t=e.childNodes.length;0<t;--t)e.removeChild(e.firstChild);return e}function y(e,t){return te(e).appendChild(t)}function M(e,t,n,r){var i=document.createElement(e);if(n&&(i.className=n),r&&(i.style.cssText=r),"string"==typeof t)i.appendChild(document.createTextNode(t));else if(t)for(var o=0;o<t.length;++o)i.appendChild(t[o]);return i}function ne(e,t,n,r){e=M(e,t,n,r);return e.setAttribute("role","presentation"),e}function re(e,t){if(3==t.nodeType&&(t=t.parentNode),e.contains)return e.contains(t);do{if((t=11==t.nodeType?t.host:t)==e)return!0}while(t=t.parentNode)}function N(e){var t,n=e.ownerDocument||e;try{t=e.activeElement}catch(e){t=n.body||null}for(;t&&t.shadowRoot&&t.shadowRoot.activeElement;)t=t.shadowRoot.activeElement;return t}function ie(e,t){var n=e.className;J(t).test(n)||(e.className+=(n?" ":"")+t)}function oe(e,t){for(var n=e.split(" "),r=0;r<n.length;r++)n[r]&&!J(n[r]).test(t)&&(t+=" "+n[r]);return t}var le=document.createRange?function(e,t,n,r){var i=document.createRange();return i.setEnd(r||e,n),i.setStart(e,t),i}:function(e,t,n){var r=document.body.createTextRange();try{r.moveToElementText(e.parentNode)}catch(e){return r}return r.collapse(!0),r.moveEnd("character",n),r.moveStart("character",t),r},se=function(e){e.select()};function ae(e){return e.display.wrapper.ownerDocument}function ue(e){return ce(e.display.wrapper)}function ce(e){return e.getRootNode?e.getRootNode():e.ownerDocument}function he(e){return ae(e).defaultView}function de(e){var t=Array.prototype.slice.call(arguments,1);return function(){return e.apply(null,t)}}function fe(e,t,n){for(var r in t=t||{},e)!e.hasOwnProperty(r)||!1===n&&t.hasOwnProperty(r)||(t[r]=e[r]);return t}function S(e,t,n,r,i){null==t&&-1==(t=e.search(/[^\s\u00a0]/))&&(t=e.length);for(var o=r||0,l=i||0;;){var s=e.indexOf("\t",o);if(s<0||t<=s)return l+(t-o);l=(l+=s-o)+(n-l%n),o=s+1}}Y?se=function(e){e.selectionStart=0,e.selectionEnd=e.value.length}:w&&(se=function(e){try{e.select()}catch(e){}});function pe(){this.id=null,this.f=null,this.time=0,this.handler=de(this.onTimeout,this)}function L(e,t){for(var n=0;n<e.length;++n)if(e[n]==t)return n;return-1}pe.prototype.onTimeout=function(e){e.id=0,e.time<=+new Date?e.f():setTimeout(e.handler,e.time-+new Date)};var ge=50,me={toString:function(){return"CodeMirror.Pass"}},ve={scroll:!(pe.prototype.set=function(e,t){this.f=t;t=+new Date+e;(!this.id||t<this.time)&&(clearTimeout(this.id),this.id=setTimeout(this.handler,e),this.time=t)})},ye={origin:"*mouse"},be={origin:"+move"};function we(e,t,n){for(var r=0,i=0;;){var o=e.indexOf("\t",r),l=(o=-1==o?e.length:o)-r;if(o==e.length||t<=i+l)return r+Math.min(l,t-i);if(i+=o-r,r=o+1,t<=(i+=n-i%n))return r}}var xe=[""];function Ce(e){for(;xe.length<=e;)xe.push(z(xe)+" ");return xe[e]}function z(e){return e[e.length-1]}function Se(e,t){for(var n=[],r=0;r<e.length;r++)n[r]=t(e[r],r);return n}function Le(){}function ke(e,t){e=Object.create?Object.create(e):(Le.prototype=e,new Le);return t&&fe(t,e),e}var Te=/[\u00df\u0587\u0590-\u05f4\u0600-\u06ff\u3040-\u309f\u30a0-\u30ff\u3400-\u4db5\u4e00-\u9fcc\uac00-\ud7af]/;function Me(e){return/\w/.test(e)||""<e&&(e.toUpperCase()!=e.toLowerCase()||Te.test(e))}function Ne(e,t){return t?!!(-1<t.source.indexOf("\\w")&&Me(e))||t.test(e):Me(e)}function Oe(e){for(var t in e)if(e.hasOwnProperty(t)&&e[t])return;return 1}var Ae=/[\u0300-\u036f\u0483-\u0489\u0591-\u05bd\u05bf\u05c1\u05c2\u05c4\u05c5\u05c7\u0610-\u061a\u064b-\u065e\u0670\u06d6-\u06dc\u06de-\u06e4\u06e7\u06e8\u06ea-\u06ed\u0711\u0730-\u074a\u07a6-\u07b0\u07eb-\u07f3\u0816-\u0819\u081b-\u0823\u0825-\u0827\u0829-\u082d\u0900-\u0902\u093c\u0941-\u0948\u094d\u0951-\u0955\u0962\u0963\u0981\u09bc\u09be\u09c1-\u09c4\u09cd\u09d7\u09e2\u09e3\u0a01\u0a02\u0a3c\u0a41\u0a42\u0a47\u0a48\u0a4b-\u0a4d\u0a51\u0a70\u0a71\u0a75\u0a81\u0a82\u0abc\u0acLine truncated
|
||||||
|
!function(t){"object"==typeof exports&&"object"==typeof module?t(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],t):t(CodeMirror)}(function(r){"use strict";var p,v,x=r.Pos;function m(t,e){for(var n,i=null!=(i=(n=t).flags)?i:(n.ignoreCase?"i":"")+(n.global?"g":"")+(n.multiline?"m":""),r=i,o=0;o<e.length;o++)-1==r.indexOf(e.charAt(o))&&(r+=e.charAt(o));return i==r?t:new RegExp(t.source,r)}function d(t){return/\\s|\\n|\n|\\W|\\D|\[\^/.test(t.source)}function a(t,e,n){e=m(e,"g");for(var i=n.line,r=n.ch,o=t.lastLine();i<=o;i++,r=0){e.lastIndex=r;var h=t.getLine(i),h=e.exec(h);if(h)return{from:x(i,h.index),to:x(i,h.index+h[0].length),match:h}}}function o(t,e,n){if(!d(e))return a(t,e,n);e=m(e,"gm");for(var i=1,r=n.line,o=t.lastLine();r<=o;){for(var h=0;h<i&&!(o<r);h++)var l=t.getLine(r++),c=null==c?l:c+"\n"+l;i*=2,e.lastIndex=n.ch;var s,f,g,u=e.exec(c);if(u)return g=c.slice(0,u.index).split("\n"),s=u[0].split("\n"),f=n.line+g.length-1,g=g[g.length-1].length,{from:x(f,g),to:x(f+s.length-1,1==s.length?g+s[0].length:s[s.length-1].length),match:u}}}function L(t,e,n){for(var i,r=0;r<=t.length;){e.lastIndex=r;var o=e.exec(t);if(!o)break;var h=o.index+o[0].length;if(h>t.length-n)break;(!i||h>i.index+i[0].length)&&(i=o),r=o.index+1}return i}function O(t,e,n){e=m(e,"g");for(var i=n.line,r=n.ch,o=t.firstLine();o<=i;i--,r=-1){var h=t.getLine(i),h=L(h,e,r<0?0:h.length-r);if(h)return{from:x(i,h.index),to:x(i,h.index+h[0].length),match:h}}}function h(t,e,n){if(!d(e))return O(t,e,n);e=m(e,"gm");for(var i=1,r=t.getLine(n.line).length-n.ch,o=n.line,h=t.firstLine();h<=o;){for(var l=0;l<i&&h<=o;l++)var c=t.getLine(o--),s=null==s?c:c+"\n"+s;i*=2;var f,g,u,a=L(s,e,r);if(a)return u=s.slice(0,a.index).split("\n"),f=a[0].split("\n"),g=o+u.length,u=u[u.length-1].length,{from:x(g,u),to:x(g+f.length-1,1==f.length?u+f[0].length:f[f.length-1].length),match:a}}}function y(t,e,n,i){if(t.length==e.length)return n;for(var r=0,o=n+Math.max(0,t.length-e.length);;){if(r==o)return r;var h=r+o>>1,l=i(t.slice(0,h)).length;if(l==n)return h;n<l?o=h:r=1+h}}function l(t,e,n,i){if(!e.length)return null;var r=i?p:v,o=r(e).split(/\r|\n\r?/);t:for(var h=n.line,l=n.ch,c=t.lastLine()+1-o.length;h<=c;h++,l=0){var s=t.getLine(h).slice(l),f=r(s);if(1==o.length){var g=f.indexOf(o[0]);if(-1==g);else return n=y(s,f,g,r)+l,{from:x(h,y(s,f,g,r)+l),to:x(h,y(s,f,g+o[0].length,r)+l)}}else{g=f.length-o[0].length;if(f.slice(g)==o[0]){for(var u=1;u<o.length-1;u++)if(r(t.getLine(h+u))!=o[u])continue t;var a=t.getLine(h+o.length-1),m=r(a),d=o[o.length-1];if(m.slice(0,d.length)==d)return{from:x(h,y(s,f,g,r)+l),to:x(h+o.length-1,y(a,m,d.length,r))}}}}}function c(t,e,n,i){if(!e.length)return null;var r=i?p:v,o=r(e).split(/\r|\n\r?/);t:for(var h=n.line,l=n.ch,c=t.firstLine()-1+o.length;c<=h;h--,l=-1){var s=t.getLine(h),f=r(s=-1<l?s.slice(0,l):s);if(1==o.length){var g=f.lastIndexOf(o[0]);if(-1!=g)return{from:x(h,y(s,f,g,r)),to:x(h,y(s,f,g+o[0].length,r))}}else{g=o[o.length-1];if(f.slice(0,g.length)==g){for(var u=1,n=h-o.length+1;u<o.length-1;u++)if(r(t.getLine(n+u))!=o[u])continue t;var a=t.getLine(h+1-o.length),m=r(a);if(m.slice(m.length-o[0].length)==o[0])return{from:x(h+1-o.length,y(a,m,a.length-o[0].length,r)),to:x(h,y(s,f,g.length,r))}}}}}function i(n,i,t,e){var r;this.atOccurrence=!1,this.afterEmptyMatch=!1,this.doc=n,t=t?n.clipPos(t):x(0,0),this.pos={from:t,to:t},"object"==typeof e?r=e.caseFold:(r=e,e=null),"string"==typeof i?(null==r&&(r=!1),this.matches=function(t,e){return(t?c:l)(n,i,e,r)}):(i=m(i,"gm"),e&&!1===e.multiline?this.matches=function(t,e){return(t?O:a)(n,i,e)}:this.matches=function(t,e){return(t?h:o)(n,i,e)})}v=String.prototype.normalize?(p=function(t){return t.normalize("NFD").toLowerCase()},function(t){return t.normalize("NFD")}):(p=function(t){return t.toLowerCase()},function(t){return t}),i.prototype={findNext:function(){return this.find(!1)},findPrevious:function(){return this.find(!0)},find:function(t){var e=this.doc.clipPos(t?this.pos.from:this.pos.to);if(this.afterEmptyMatch&&this.atOccurrence&&(e=x(e.line,e.ch),t?(e.ch--,e.ch<0&&(e.line--,e.ch=(this.doc.getLine(e.line)||"").length)):(e.ch++,e.ch>(this.doc.getLine(e.line)||"").length&&(e.ch=0,e.line++)),0!=r.cmpPos(e,this.doc.clipPos(e))))return this.atOccurrence=!1;var e=this.matches(t,e);return this.afterEmptyMatch=e&&0==r.cmpPos(e.from,e.to),e?(this.pos=e,this.atOccurrence=!0,this.pos.match||!0):(e=x(t?this.doc.firstLine():this.doc.lastLine()+1,0),this.pos={from:e,to:e},this.atOccurrence=!1)},from:function(){if(this.atOccurrence)return this.pos.from},to:function(){if(this.atOccurrence)return this.pos.to},replace:function(t,e){this.atOccurrence&&(t=r.splitLines(t),this.doc.replaceRange(t,this.pos.from,this.pos.to,e),this.pos.to=x(this.pos.from.line+t.length-1,t[t.length-1].length+(1==t.length?this.pos.from.ch:0)))}},r.defineExtension("getSearchCursor",function(t,e,n){return new i(this.doc,t,e,n)}),r.defineDocExtension("getSearchCursor",function(t,Line truncated
|
||||||
|
!function(t){"object"==typeof exports&&"object"==typeof module?t(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],t):t(CodeMirror)}(function(r){var u=/MSIE \d/.test(navigator.userAgent)&&(null==document.documentMode||document.documentMode<8),k=r.Pos,p={"(":")>",")":"(<","[":"]>","]":"[<","{":"}>","}":"{<","<":">>",">":"<<"};function y(t){return t&&t.bracketRegex||/[(){}[\]]/}function f(t,e,n){var r=t.getLineHandle(e.line),i=e.ch-1,c=n&&n.afterCursor,a=(null==c&&(c=/(^| )cm-fat-cursor($| )/.test(t.getWrapperElement().className)),y(n)),c=!c&&0<=i&&a.test(r.text.charAt(i))&&p[r.text.charAt(i)]||a.test(r.text.charAt(i+1))&&p[r.text.charAt(++i)];if(!c)return null;a=">"==c.charAt(1)?1:-1;if(n&&n.strict&&0<a!=(i==e.ch))return null;r=t.getTokenTypeAt(k(e.line,i+1)),t=o(t,k(e.line,i+(0<a?1:0)),a,r,n);return null==t?null:{from:k(e.line,i),to:t&&t.pos,match:t&&t.ch==c.charAt(0),forward:0<a}}function o(t,e,n,r,i){for(var c=i&&i.maxScanLineLength||1e4,a=i&&i.maxScanLines||1e3,o=[],h=y(i),l=0<n?Math.min(e.line+a,t.lastLine()+1):Math.max(t.firstLine()-1,e.line-a),s=e.line;s!=l;s+=n){var u=t.getLine(s);if(u){var f=0<n?0:u.length-1,m=0<n?u.length:-1;if(!(u.length>c))for(s==e.line&&(f=e.ch-(n<0?1:0));f!=m;f+=n){var g=u.charAt(f);if(h.test(g)&&(void 0===r||(t.getTokenTypeAt(k(s,f+1))||"")==(r||""))){var d=p[g];if(d&&">"==d.charAt(1)==0<n)o.push(g);else{if(!o.length)return{pos:k(s,f),ch:g};o.pop()}}}}}return s-n!=(0<n?t.lastLine():t.firstLine())&&null}function e(t,e,n){for(var r=t.state.matchBrackets.maxHighlightLineLength||1e3,i=n&&n.highlightNonMatching,c=[],a=t.listSelections(),o=0;o<a.length;o++){var h,l=a[o].empty()&&f(t,a[o].head,n);l&&(l.match||!1!==i)&&t.getLine(l.from.line).length<=r&&(h=l.match?"CodeMirror-matchingbracket":"CodeMirror-nonmatchingbracket",c.push(t.markText(l.from,k(l.from.line,l.from.ch+1),{className:h})),l.to&&t.getLine(l.to.line).length<=r&&c.push(t.markText(l.to,k(l.to.line,l.to.ch+1),{className:h})))}if(c.length){u&&t.state.focused&&t.focus();function s(){t.operation(function(){for(var t=0;t<c.length;t++)c[t].clear()})}if(!e)return s;setTimeout(s,800)}}function i(t){t.operation(function(){t.state.matchBrackets.currentlyHighlighted&&(t.state.matchBrackets.currentlyHighlighted(),t.state.matchBrackets.currentlyHighlighted=null),t.state.matchBrackets.currentlyHighlighted=e(t,!1,t.state.matchBrackets)})}function c(t){t.state.matchBrackets&&t.state.matchBrackets.currentlyHighlighted&&(t.state.matchBrackets.currentlyHighlighted(),t.state.matchBrackets.currentlyHighlighted=null)}r.defineOption("matchBrackets",!1,function(t,e,n){n&&n!=r.Init&&(t.off("cursorActivity",i),t.off("focus",i),t.off("blur",c),c(t)),e&&(t.state.matchBrackets="object"==typeof e?e:{},t.on("cursorActivity",i),t.on("focus",i),t.on("blur",c))}),r.defineExtension("matchBrackets",function(){e(this,!0)}),r.defineExtension("findMatchingBracket",function(t,e,n){return f(this,t,e=!n&&"boolean"!=typeof e?e:n?(n.strict=e,n):e?{strict:!0}:null)}),r.defineExtension("scanForBracket",function(t,e,n,r){return o(this,t,e,n,r)})});;
|
||||||
|
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(v){"use strict";function h(e,t){if(!e.hasOwnProperty(t))throw new Error("Undefined state "+t+" in simple mode")}function k(e,t){if(!e)return/(?:)/;var n="";return e=e instanceof RegExp?(e.ignoreCase&&(n="i"),e.unicode&&(n+="u"),e.source):String(e),new RegExp((!1===t?"":"^")+"(?:"+e+")",n)}function g(e,t){(e.next||e.push)&&h(t,e.next||e.push),this.regex=k(e.regex),this.token=function(e){if(!e)return null;if(e.apply)return e;if("string"==typeof e)return e.replace(/\./g," ");for(var t=[],n=0;n<e.length;n++)t.push(e[n]&&e[n].replace(/\./g," "));return t}(e.token),this.data=e}v.defineSimpleMode=function(e,t){v.defineMode(e,function(e){return v.simpleMode(e,t)})},v.simpleMode=function(e,t){h(t,"start");var n,a={},o=t.meta||{},r=!1;for(n in t)if(n!=o&&t.hasOwnProperty(n))for(var i=a[n]=[],l=t[n],s=0;s<l.length;s++){var d=l[s];i.push(new g(d,t)),(d.indent||d.dedent)&&(r=!0)}var c,p,S,m,u={startState:function(){return{state:"start",pending:null,local:null,localState:null,indent:r?[]:null}},copyState:function(e){var t={state:e.state,pending:e.pending,local:e.local,localState:null,indent:e.indent&&e.indent.slice(0)};e.localState&&(t.localState=v.copyState(e.local.mode,e.localState)),e.stack&&(t.stack=e.stack.slice(0));for(var n=e.persistentStates;n;n=n.next)t.persistentStates={mode:n.mode,spec:n.spec,state:n.state==e.localState?t.localState:v.copyState(n.mode,n.state),next:t.persistentStates};return t},token:(m=e,function(e,t){var n,a;if(t.pending)return a=t.pending.shift(),0==t.pending.length&&(t.pending=null),e.pos+=a.text.length,a.token;if(t.local)return t.local.end&&e.match(t.local.end)?(n=t.local.endToken||null,t.local=t.localState=null):(n=t.local.mode.token(e,t.localState),t.local.endScan&&(a=t.local.endScan.exec(e.current()))&&(e.pos=e.start+a.index)),n;for(var o=S[t.state],r=0;r<o.length;r++){var i=o[r],l=(!i.data.sol||e.sol())&&e.match(i.regex);if(l){if(i.data.next?t.state=i.data.next:i.data.push?((t.stack||(t.stack=[])).push(t.state),t.state=i.data.push):i.data.pop&&t.stack&&t.stack.length&&(t.state=t.stack.pop()),i.data.mode){h=d=f=s=u=p=c=d=void 0;var s,d=m,c=t,p=i.data.mode,u=i.token;if(p.persistent)for(var f=c.persistentStates;f&&!s;f=f.next)(p.spec?function e(t,n){if(t===n)return!0;if(!t||"object"!=typeof t||!n||"object"!=typeof n)return!1;var a=0;for(var o in t)if(t.hasOwnProperty(o)){if(!n.hasOwnProperty(o)||!e(t[o],n[o]))return!1;a++}for(var o in n)n.hasOwnProperty(o)&&a--;return 0==a}(p.spec,f.spec):p.mode==f.mode)&&(s=f);var d=s?s.mode:p.mode||v.getMode(d,p.spec),h=s?s.state:v.startState(d);p.persistent&&!s&&(c.persistentStates={mode:d,spec:p.spec,state:h,next:c.persistentStates}),c.localState=h,c.local={mode:d,end:p.end&&k(p.end),endScan:p.end&&!1!==p.forceEnd&&k(p.end,!1),endToken:u&&u.join?u[u.length-1]:u}}i.data.indent&&t.indent.push(e.indentation()+m.indentUnit),i.data.dedent&&t.indent.pop();h=i.token;if(h&&h.apply&&(h=h(l)),2<l.length&&i.token&&"string"!=typeof i.token){for(var g=2;g<l.length;g++)l[g]&&(t.pending||(t.pending=[])).push({text:l[g],token:i.token[g-1]});return e.backUp(l[0].length-(l[1]?l[1].length:0)),h[0]}return h&&h.join?h[0]:h}}return e.next(),null}),innerMode:function(e){return e.local&&{mode:e.local.mode,state:e.localState}},indent:(c=S=a,function(e,t,n){if(e.local&&e.local.mode.indent)return e.local.mode.indent(e.localState,t,n);if(null==e.indent||e.local||p.dontIndentStates&&-1<function(e,t){for(var n=0;n<t.length;n++)if(t[n]===e)return!0}(e.state,p.dontIndentStates))return v.Pass;var a=e.indent.length-1,o=c[e.state];e:for(;;){for(var r=0;r<o.length;r++){var i=o[r];if(i.data.dedent&&!1!==i.data.dedentIfLineStart){var l=i.regex.exec(t);if(l&&l[0]){a--,(i.next||i.push)&&(o=c[i.next||i.push]),t=t.slice(l[0].length);continue e}}}break}return a<0?0:e.indent[a]})};if(p=o)for(var f in o)o.hasOwnProperty(f)&&(u[f]=o[f]);return u}});;
|
||||||
|
!function(t){"object"==typeof exports&&"object"==typeof module?t(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],t):t(CodeMirror)}(function(y){"use strict";var C={autoSelfClosers:{area:!0,base:!0,br:!0,col:!0,command:!0,embed:!0,frame:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0,menuitem:!0},implicitlyClosed:{dd:!0,li:!0,optgroup:!0,option:!0,p:!0,rp:!0,rt:!0,tbody:!0,td:!0,tfoot:!0,th:!0,tr:!0},contextGrabbers:{dd:{dd:!0,dt:!0},dt:{dd:!0,dt:!0},li:{li:!0},option:{option:!0,optgroup:!0},optgroup:{optgroup:!0},p:{address:!0,article:!0,aside:!0,blockquote:!0,dir:!0,div:!0,dl:!0,fieldset:!0,footer:!0,form:!0,h1:!0,h2:!0,h3:!0,h4:!0,h5:!0,h6:!0,header:!0,hgroup:!0,hr:!0,menu:!0,nav:!0,ol:!0,p:!0,pre:!0,section:!0,table:!0,ul:!0},rp:{rp:!0,rt:!0},rt:{rp:!0,rt:!0},tbody:{tbody:!0,tfoot:!0},td:{td:!0,th:!0},tfoot:{tbody:!0},th:{td:!0,th:!0},thead:{tbody:!0,tfoot:!0},tr:{tr:!0}},doNotIndent:{pre:!0},allowUnquoted:!0,allowMissing:!0,caseFold:!0},z={autoSelfClosers:{},implicitlyClosed:{},contextGrabbers:{},doNotIndent:{},allowUnquoted:!1,allowMissing:!1,allowMissingTagName:!1,caseFold:!1};y.defineMode("xml",function(t,e){var n,i,a,l=t.indentUnit,u={},r=e.htmlMode?C:z;for(n in r)u[n]=r[n];for(n in e)u[n]=e[n];function c(e,n){function t(t){return(n.tokenize=t)(e,n)}var r=e.next();return"<"==r?e.eat("!")?e.eat("[")?e.match("CDATA[")?t(o("atom","]]>")):null:e.match("--")?t(o("comment","--\x3e")):e.match("DOCTYPE",!0,!0)?(e.eatWhile(/[\w\._\-]/),t(function r(o){return function(t,e){for(var n;null!=(n=t.next());){if("<"==n)return e.tokenize=r(o+1),e.tokenize(t,e);if(">"==n){if(1!=o)return e.tokenize=r(o-1),e.tokenize(t,e);e.tokenize=c;break}}return"meta"}}(1))):null:e.eat("?")?(e.eatWhile(/[\w\._\-]/),n.tokenize=o("meta","?>"),"meta"):(i=e.eat("/")?"closeTag":"openTag",n.tokenize=d,"tag bracket"):"&"==r?(e.eat("#")?e.eat("x")?e.eatWhile(/[a-fA-F\d]/)&&e.eat(";"):e.eatWhile(/[\d]/)&&e.eat(";"):e.eatWhile(/[\w\.\-:]/)&&e.eat(";"))?"atom":"error":(e.eatWhile(/[^&<]/),null)}function d(t,e){var n,r,o=t.next();return">"==o||"/"==o&&t.eat(">")?(e.tokenize=c,i=">"==o?"endTag":"selfcloseTag","tag bracket"):"="==o?(i="equals",null):"<"==o?(e.tokenize=c,e.state=p,e.tagName=e.tagStart=null,(n=e.tokenize(t,e))?n+" tag error":"tag error"):/[\'\"]/.test(o)?(e.tokenize=(r=o,a.isInAttribute=!0,a),e.stringStartCol=t.column(),e.tokenize(t,e)):(t.match(/^[^\s\u00a0=<>\"\']*[^\s\u00a0=<>\"\'\/]/),"word");function a(t,e){for(;!t.eol();)if(t.next()==r){e.tokenize=d;break}return"string"}}function o(n,r){return function(t,e){for(;!t.eol();){if(t.match(r)){e.tokenize=c;break}t.next()}return n}}function s(t){return t&&t.toLowerCase()}function f(t,e,n){this.prev=t.context,this.tagName=e||"",this.indent=t.indented,this.startOfLine=n,(u.doNotIndent.hasOwnProperty(e)||t.context&&t.context.noIndent)&&(this.noIndent=!0)}function m(t){t.context&&(t.context=t.context.prev)}function g(t,e){for(var n;;){if(!t.context)return;if(n=t.context.tagName,!u.contextGrabbers.hasOwnProperty(s(n))||!u.contextGrabbers[s(n)].hasOwnProperty(s(e)))return;m(t)}}function p(t,e,n){return"openTag"==t?(n.tagStart=e.column(),h):"closeTag"==t?x:p}function h(t,e,n){return"word"==t?(n.tagName=e.current(),a="tag",w):u.allowMissingTagName&&"endTag"==t?(a="tag bracket",w(t,0,n)):(a="error",h)}function x(t,e,n){var r;return"word"==t?(r=e.current(),n.context&&n.context.tagName!=r&&u.implicitlyClosed.hasOwnProperty(s(n.context.tagName))&&m(n),n.context&&n.context.tagName==r||!1===u.matchClosing?(a="tag",b):(a="tag error",k)):u.allowMissingTagName&&"endTag"==t?(a="tag bracket",b(t,0,n)):(a="error",k)}function b(t,e,n){return"endTag"!=t?(a="error",b):(m(n),p)}function k(t,e,n){return a="error",b(t,0,n)}function w(t,e,n){return"word"==t?(a="attribute",T):"endTag"==t||"selfcloseTag"==t?(r=n.tagName,o=n.tagStart,n.tagName=n.tagStart=null,"selfcloseTag"==t||u.autoSelfClosers.hasOwnProperty(s(r))?g(n,r):(g(n,r),n.context=new f(n,r,o==n.indented)),p):(a="error",w);var r,o}function T(t,e,n){return"equals"==t?v:(u.allowMissing||(a="error"),w(t,0,n))}function v(t,e,n){return"string"==t?N:"word"==t&&u.allowUnquoted?(a="string",w):(a="error",w(t,0,n))}function N(t,e,n){return"string"==t?N:w(t,0,n)}return c.isInText=!0,{startState:function(t){var e={tokenize:c,state:p,indented:t||0,tagName:null,tagStart:null,context:null};return null!=t&&(e.baseIndent=t),e},token:function(t,e){if(!e.tagName&&t.sol()&&(e.indented=t.indentation()),t.eatSpace())return null;i=null;var n=e.tokenize(t,e);return(n||i)&&"comment"!=n&&(a=null,e.state=e.state(i||n,t,e),a&&(n="error"==a?n+" error":a)),n},indent:function(t,e,n){var r=t.context;if(t.tokenize.isInAttribute)return t.tagStart==t.indented?t.stringStartCol+1:t.indented+l;if(r&&r.noIndent)return y.Pass;if(t.tokenize!=d&&t.tokenize!=c)return n?n.match(/^(\s*)/)[0].length:0;if(t.tagName)return!1!==u.multilineTagIndentPastTag?t.tagStart+t.tagName.length+2:t.tagStart+l*(u.multilinLine truncated
|
||||||
|
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(rt){"use strict";rt.defineMode("javascript",function(e,l){var t,r,O,P,f=e.indentUnit,N=l.statementIndent,U=l.jsonld,o=l.json||U,W=!1!==l.trackScope,u=l.typescript,B=l.wordCharacters||/[\w$\xa1-\uffff]/,F=(e=n("keyword a"),t=n("keyword b"),r=n("keyword c"),O=n("keyword d"),P=n("operator"),{if:n("if"),while:e,with:e,else:t,do:t,try:t,finally:t,return:O,break:O,continue:O,new:n("new"),delete:r,void:r,throw:r,debugger:n("debugger"),var:n("var"),const:n("var"),let:n("var"),function:n("function"),catch:n("catch"),for:n("for"),switch:n("switch"),case:n("case"),default:n("default"),in:P,typeof:P,instanceof:P,true:e={type:"atom",style:"atom"},false:e,null:e,undefined:e,NaN:e,Infinity:e,this:n("this"),class:n("class"),super:n("atom"),yield:r,export:n("export"),import:n("import"),extends:r,await:r});function n(e){return{type:e,style:"keyword"}}var H,D,G=/[+\-*&%=<>!?|~^@]/,J=/^@(context|id|value|language|type|container|list|set|reverse|index|base|vocab|graph)"/;function i(e,t,r){return H=e,D=r,t}function d(e,t){var a,r=e.next();if('"'==r||"'"==r)return t.tokenize=(a=r,function(e,t){var r,n=!1;if(U&&"@"==e.peek()&&e.match(J))return t.tokenize=d,i("jsonld-keyword","meta");for(;null!=(r=e.next())&&(r!=a||n);)n=!n&&"\\"==r;return n||(t.tokenize=d),i("string","string")}),t.tokenize(e,t);if("."==r&&e.match(/^\d[\d_]*(?:[eE][+\-]?[\d_]+)?/))return i("number","number");if("."==r&&e.match(".."))return i("spread","meta");if(/[\[\]{}\(\),;\:\.]/.test(r))return i(r);if("="==r&&e.eat(">"))return i("=>","operator");if("0"==r&&e.match(/^(?:x[\dA-Fa-f_]+|o[0-7_]+|b[01_]+)n?/))return i("number","number");if(/\d/.test(r))return e.match(/^[\d_]*(?:n|(?:\.[\d_]*)?(?:[eE][+\-]?[\d_]+)?)?/),i("number","number");if("/"==r)return e.eat("*")?(t.tokenize=K)(e,t):e.eat("/")?(e.skipToEnd(),i("comment","comment")):tt(e,t,1)?(function(e){for(var t,r=!1,n=!1;null!=(t=e.next());){if(!r){if("/"==t&&!n)return;"["==t?n=!0:n&&"]"==t&&(n=!1)}r=!r&&"\\"==t}}(e),e.match(/^\b(([gimyus])(?![gimyus]*\2))+\b/),i("regexp","string-2")):(e.eat("="),i("operator","operator",e.current()));if("`"==r)return(t.tokenize=L)(e,t);if("#"==r&&"!"==e.peek())return e.skipToEnd(),i("meta","meta");if("#"==r&&e.eatWhile(B))return i("variable","property");if("<"==r&&e.match("!--")||"-"==r&&e.match("->")&&!/\S/.test(e.string.slice(0,e.start)))return e.skipToEnd(),i("comment","comment");if(G.test(r))return">"==r&&t.lexical&&">"==t.lexical.type||(e.eat("=")?"!"!=r&&"="!=r||e.eat("="):/[<>*+\-|&?]/.test(r)&&(e.eat(r),">"==r&&e.eat(r))),"?"==r&&e.eat(".")?i("."):i("operator","operator",e.current());if(B.test(r)){e.eatWhile(B);r=e.current();if("."!=t.lastType){if(F.propertyIsEnumerable(r))return i((t=F[r]).type,t.style,r);if("async"==r&&e.match(/^(\s|\/\*([^*]|\*(?!\/))*?\*\/)*[\[\(\w]/,!1))return i("async","keyword",r)}return i("variable","variable",r)}}function K(e,t){for(var r,n=!1;r=e.next();){if("/"==r&&n){t.tokenize=d;break}n="*"==r}return i("comment","comment")}function L(e,t){for(var r,n=!1;null!=(r=e.next());){if(!n&&("`"==r||"$"==r&&e.eat("{"))){t.tokenize=d;break}n=!n&&"\\"==r}return i("quasi","string-2",e.current())}function Q(e,t){t.fatArrowAt&&(t.fatArrowAt=null);var r=e.string.indexOf("=>",e.start);if(!(r<0)){!u||(n=/:\s*(?:\w+(?:<[^>]*>|\[\])?|\{[^}]*\})\s*$/.exec(e.string.slice(e.start,r)))&&(r=n.index);for(var n,a=0,i=!1,o=r-1;0<=o;--o){var c=e.string.charAt(o),s="([{}])".indexOf(c);if(0<=s&&s<3){if(!a){++o;break}if(0==--a){"("==c&&(i=!0);break}}else if(3<=s&&s<6)++a;else if(B.test(c))i=!0;else if(/["'\/`]/.test(c))for(;;--o){if(0==o)return;if(e.string.charAt(o-1)==c&&"\\"!=e.string.charAt(o-2)){o--;break}}else if(i&&!a){++o;break}}i&&!a&&(t.fatArrowAt=o)}}var R={atom:!0,number:!0,variable:!0,string:!0,regexp:!0,this:!0,import:!0,"jsonld-keyword":!0};function X(e,t,r,n,a,i){this.indented=e,this.column=t,this.type=r,this.prev=a,this.info=i,null!=n&&(this.align=n)}function Y(e,t,r,n,a){var i=e.cc;for(c.state=e,c.stream=a,c.marked=null,c.cc=i,c.style=t,e.lexical.hasOwnProperty("align")||(e.lexical.align=!0);;)if((i.length?i.pop():o?x:b)(r,n)){for(;i.length&&i[i.length-1].lex;)i.pop()();return c.marked?c.marked:"variable"==r&&function(e,t){if(W){for(var r=e.localVars;r;r=r.next)if(r.name==t)return 1;for(var n=e.context;n;n=n.prev)for(r=n.vars;r;r=r.next)if(r.name==t)return 1}}(e,n)?"variable-2":t}}var c={state:null,column:null,marked:null,cc:null};function s(){for(var e=arguments.length-1;0<=e;e--)c.cc.push(arguments[e])}function p(){return s.apply(null,arguments),!0}function Z(e,t){for(var r=t;r;r=r.next)if(r.name==e)return 1}function a(e){var t=c.state;if(c.marked="def",W){if(t.context)if("var"==t.lexical.info&&t.context&&t.context.block){var r=function e(t,r){{var n;return r?r.block?(n=e(t,r.prev))?n==r.prev?r:new te(n,r.vars,!0):null:Z(t,r.vars)?r:new te(r.prev,new re(t,r.varLine truncated
|
||||||
|
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(T){"use strict";function e(e){for(var t={},r=0;r<e.length;++r)t[e[r].toLowerCase()]=!0;return t}T.defineMode("css",function(e,t){var a,i,r=t.inline,n=(t.propertyKeywords||(t=T.resolveMode("text/css")),e.indentUnit),l=t.tokenHooks,o=t.documentTypes||{},s=t.mediaTypes||{},c=t.mediaFeatures||{},d=t.mediaValueKeywords||{},p=t.propertyKeywords||{},u=t.nonStandardPropertyKeywords||{},m=t.fontProperties||{},g=t.counterDescriptors||{},b=t.colorKeywords||{},h=t.valueKeywords||{},f=t.allowNested,k=t.lineComment,y=!0===t.supportsAtComponent,w=!1!==e.highlightNonStandardPropertyKeywords;function v(e,t){return a=t,e}function x(i){return function(e,t){for(var r,o=!1;null!=(r=e.next());){if(r==i&&!o){")"==i&&e.backUp(1);break}o=!o&&"\\"==r}return r!=i&&(o||")"==i)||(t.tokenize=null),a="string"}}function z(e,t){return e.next(),e.match(/^\s*[\"\')]/,!1)?t.tokenize=null:t.tokenize=x(")"),a="(",null}function j(e,t,r){this.type=e,this.indent=t,this.prev=r}function P(e,t,r,o){return e.context=new j(r,t.indentation()+(!1===o?0:n),e.context),r}function K(e){return e.context.prev&&(e.context=e.context.prev),e.context.type}function q(e,t,r){return _[r.context.type](e,t,r)}function C(e,t,r,o){for(var i=o||1;0<i;i--)r.context=r.context.prev;return q(e,t,r)}function B(e){e=e.current().toLowerCase();i=h.hasOwnProperty(e)?"atom":b.hasOwnProperty(e)?"keyword":"variable"}var _={top:function(e,t,r){if("{"==e)return P(r,t,"block");if("}"==e&&r.context.prev)return K(r);if(y&&/@component/i.test(e))return P(r,t,"atComponentBlock");if(/^@(-moz-)?document$/i.test(e))return P(r,t,"documentTypes");if(/^@(media|supports|(-moz-)?document|import)$/i.test(e))return P(r,t,"atBlock");if(/^@(font-face|counter-style)/i.test(e))return r.stateArg=e,"restricted_atBlock_before";if(/^@(-(moz|ms|o|webkit)-)?keyframes$/i.test(e))return"keyframes";if(e&&"@"==e.charAt(0))return P(r,t,"at");if("hash"==e)i="builtin";else if("word"==e)i="tag";else{if("variable-definition"==e)return"maybeprop";if("interpolation"==e)return P(r,t,"interpolation");if(":"==e)return"pseudo";if(f&&"("==e)return P(r,t,"parens")}return r.context.type},block:function(e,t,r){var o;return"word"==e?(o=t.current().toLowerCase(),p.hasOwnProperty(o)?(i="property","maybeprop"):u.hasOwnProperty(o)?(i=w?"string-2":"property","maybeprop"):f?(i=t.match(/^\s*:(?:\s|$)/,!1)?"property":"tag","block"):(i+=" error","maybeprop")):"meta"==e?"block":f||"hash"!=e&&"qualifier"!=e?_.top(e,t,r):(i="error","block")},maybeprop:function(e,t,r){return":"==e?P(r,t,"prop"):q(e,t,r)},prop:function(e,t,r){if(";"==e)return K(r);if("{"==e&&f)return P(r,t,"propBlock");if("}"==e||"{"==e)return C(e,t,r);if("("==e)return P(r,t,"parens");if("hash"!=e||/^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(t.current())){if("word"==e)B(t);else if("interpolation"==e)return P(r,t,"interpolation")}else i+=" error";return"prop"},propBlock:function(e,t,r){return"}"==e?K(r):"word"==e?(i="property","maybeprop"):r.context.type},parens:function(e,t,r){return"{"==e||"}"==e?C(e,t,r):")"==e?K(r):"("==e?P(r,t,"parens"):"interpolation"==e?P(r,t,"interpolation"):("word"==e&&B(t),"parens")},pseudo:function(e,t,r){return"meta"==e?"pseudo":"word"==e?(i="variable-3",r.context.type):q(e,t,r)},documentTypes:function(e,t,r){return"word"==e&&o.hasOwnProperty(t.current())?(i="tag",r.context.type):_.atBlock(e,t,r)},atBlock:function(e,t,r){return"("==e?P(r,t,"atBlock_parens"):"}"==e||";"==e?C(e,t,r):"{"==e?K(r)&&P(r,t,f?"block":"top"):"interpolation"==e?P(r,t,"interpolation"):("word"==e&&(e=t.current().toLowerCase(),i="only"==e||"not"==e||"and"==e||"or"==e?"keyword":s.hasOwnProperty(e)?"attribute":c.hasOwnProperty(e)?"property":d.hasOwnProperty(e)?"keyword":p.hasOwnProperty(e)?"property":u.hasOwnProperty(e)?w?"string-2":"property":h.hasOwnProperty(e)?"atom":b.hasOwnProperty(e)?"keyword":"error"),r.context.type)},atComponentBlock:function(e,t,r){return"}"==e?C(e,t,r):"{"==e?K(r)&&P(r,t,f?"block":"top",!1):("word"==e&&(i="error"),r.context.type)},atBlock_parens:function(e,t,r){return")"==e?K(r):"{"==e||"}"==e?C(e,t,r,2):_.atBlock(e,t,r)},restricted_atBlock_before:function(e,t,r){return"{"==e?P(r,t,"restricted_atBlock"):"word"==e&&"@counter-style"==r.stateArg?(i="variable","restricted_atBlock_before"):q(e,t,r)},restricted_atBlock:function(e,t,r){return"}"==e?(r.stateArg=null,K(r)):"word"==e?(i="@font-face"==r.stateArg&&!m.hasOwnProperty(t.current().toLowerCase())||"@counter-style"==r.stateArg&&!g.hasOwnProperty(t.current().toLowerCase())?"error":"property","maybeprop"):"restricted_atBlock"},keyframes:function(e,t,r){return"word"==e?(i="variable","keyframes"):"{"==e?P(r,t,"top"):q(e,t,r)},at:function(e,t,r){return";"==e?K(r):"{"==e||"}"==e?C(e,t,r):("word"==e?i="tag":"hash"==e&&(i="builtin"),"at")},interpolation:function(e,t,r){return"}"==e?K(r):"{"==e||";"==e?C(e,tLine truncated
|
||||||
|
!function(t){"object"==typeof exports&&"object"==typeof module?t(require("../../lib/codemirror"),require("../xml/xml"),require("../javascript/javascript"),require("../css/css")):"function"==typeof define&&define.amd?define(["../../lib/codemirror","../xml/xml","../javascript/javascript","../css/css"],t):t(CodeMirror)}(function(m){"use strict";var l={script:[["lang",/(javascript|babel)/i,"javascript"],["type",/^(?:text|application)\/(?:x-)?(?:java|ecma)script$|^module$|^$/i,"javascript"],["type",/./,"text/plain"],[null,null,"javascript"]],style:[["lang",/^css$/i,"css"],["type",/^(text\/)?(x-)?(stylesheet|css)$/i,"css"],["type",/./,"text/plain"],[null,null,"css"]]};var a={};function d(t,e){e=t.match(a[t=e]||(a[t]=new RegExp("\\s+"+t+"\\s*=\\s*('|\")?([^'\"]+)('|\")?\\s*")));return e?/^\s*(.*?)\s*$/.exec(e[2])[1]:""}function g(t,e){return new RegExp((e?"^":"")+"</\\s*"+t+"\\s*>","i")}function o(t,e){for(var a in t)for(var n=e[a]||(e[a]=[]),l=t[a],o=l.length-1;0<=o;o--)n.unshift(l[o])}m.defineMode("htmlmixed",function(i,t){var c=m.getMode(i,{name:"xml",htmlMode:!0,multilineTagIndentFactor:t.multilineTagIndentFactor,multilineTagIndentPastTag:t.multilineTagIndentPastTag,allowMissingTagName:t.allowMissingTagName}),s={},e=t&&t.tags,a=t&&t.scriptTypes;if(o(l,s),e&&o(e,s),a)for(var n=a.length-1;0<=n;n--)s.script.unshift(["type",a[n].matches,a[n].mode]);function u(t,e){var a,o,r,n=c.token(t,e.htmlState),l=/\btag\b/.test(n);return l&&!/[<>\s\/]/.test(t.current())&&(a=e.htmlState.tagName&&e.htmlState.tagName.toLowerCase())&&s.hasOwnProperty(a)?e.inTag=a+" ":e.inTag&&l&&/>$/.test(t.current())?(a=/^([\S]+) (.*)/.exec(e.inTag),e.inTag=null,l=">"==t.current()&&function(t,e){for(var a=0;a<t.length;a++){var n=t[a];if(!n[0]||n[1].test(d(e,n[0])))return n[2]}}(s[a[1]],a[2]),l=m.getMode(i,l),o=g(a[1],!0),r=g(a[1],!1),e.token=function(t,e){return t.match(o,!1)?(e.token=u,e.localState=e.localMode=null):(a=t,n=r,t=e.localMode.token(t,e.localState),e=a.current(),-1<(l=e.search(n))?a.backUp(e.length-l):e.match(/<\/?$/)&&(a.backUp(e.length),a.match(n,!1)||a.match(e)),t);var a,n,l},e.localMode=l,e.localState=m.startState(l,c.indent(e.htmlState,"",""))):e.inTag&&(e.inTag+=t.current(),t.eol()&&(e.inTag+=" ")),n}return{startState:function(){return{token:u,inTag:null,localMode:null,localState:null,htmlState:m.startState(c)}},copyState:function(t){var e;return t.localState&&(e=m.copyState(t.localMode,t.localState)),{token:t.token,inTag:t.inTag,localMode:t.localMode,localState:e,htmlState:m.copyState(c,t.htmlState)}},token:function(t,e){return e.token(t,e)},indent:function(t,e,a){return!t.localMode||/^\s*<\//.test(e)?c.indent(t.htmlState,e,a):t.localMode.indent?t.localMode.indent(t.localState,e,a):m.Pass},innerMode:function(t){return{state:t.localState||t.htmlState,mode:t.localMode||c}}}},"xml","javascript","css"),m.defineMIME("text/html","htmlmixed")});;
|
||||||
|
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(M){"use strict";function L(e,t,n,r,o,a){this.indented=e,this.column=t,this.type=n,this.info=r,this.align=o,this.prev=a}function D(e,t,n,r){var o=e.indented;e.context&&"statement"==e.context.type&&"statement"!=n&&(o=e.context.indented),e.context=new L(o,t,n,r,null,e.context)}function P(e){var t=e.context.type;return")"!=t&&"]"!=t&&"}"!=t||(e.indented=e.context.indented),e.context=e.context.prev}function E(e,t,n){return"variable"==t.prevToken||"type"==t.prevToken||(!!/\S(?:[^- ]>|[*\]])\s*$|\*$/.test(e.string.slice(0,n))||(!(!t.typeAtEndOfLine||e.column()!=e.indentation())||void 0))}function z(e){for(;;){if(!e||"top"==e.type)return 1;if("}"==e.type&&"namespace"!=e.prev.info)return;e=e.prev}}function e(e){for(var t={},n=e.split(" "),r=0;r<n.length;++r)t[n[r]]=!0;return t}function F(e,t){return"function"==typeof e?e(t):e.propertyIsEnumerable(t)}M.defineMode("clike",function(e,a){var i,l,s=e.indentUnit,c=a.statementIndentUnit||s,u=a.dontAlignCalls,o=a.keywords||{},d=a.types||{},f=a.builtin||{},p=a.blockKeywords||{},m=a.defKeywords||{},h=a.atoms||{},y=a.hooks||{},g=a.multiLineStrings,k=!1!==a.indentStatements,e=!1!==a.indentSwitch,x=a.namespaceSeparator,b=a.isPunctuationChar||/[\[\]{}\(\),;\:\.]/,v=a.numberStart||/[\d\.]/,w=a.number||/^(?:0x[a-f\d]+|0b[01]+|(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?)(u|ll?|l|f)?/i,_=a.isOperatorChar||/[+\-*&%=<>!?|\/]/,S=a.isIdentifierChar||/[\w\$_\xa1-\uffff]/,T=a.isReservedIdentifier||!1;function C(e,t){var a,n=e.next();if(y[n]){var r=y[n](e,t);if(!1!==r)return r}if('"'==n||"'"==n)return t.tokenize=(a=n,function(e,t){for(var n,r=!1,o=!1;null!=(n=e.next());){if(n==a&&!r){o=!0;break}r=!r&&"\\"==n}return!o&&(r||g)||(t.tokenize=null),"string"}),t.tokenize(e,t);if(v.test(n)){if(e.backUp(1),e.match(w))return"number";e.next()}if(b.test(n))return i=n,null;if("/"==n){if(e.eat("*"))return(t.tokenize=I)(e,t);if(e.eat("/"))return e.skipToEnd(),"comment"}if(_.test(n)){for(;!e.match(/^\/[\/*]/,!1)&&e.eat(_););return"operator"}if(e.eatWhile(S),x)for(;e.match(x);)e.eatWhile(S);r=e.current();return F(o,r)?(F(p,r)&&(i="newstatement"),F(m,r)&&(l=!0),"keyword"):F(d,r)?"type":F(f,r)||T&&T(r)?(F(p,r)&&(i="newstatement"),"builtin"):F(h,r)?"atom":"variable"}function I(e,t){for(var n,r=!1;n=e.next();){if("/"==n&&r){t.tokenize=null;break}r="*"==n}return"comment"}function N(e,t){a.typeFirstDefinitions&&e.eol()&&z(t.context)&&(t.typeAtEndOfLine=E(e,t,e.pos))}return{startState:function(e){return{tokenize:null,context:new L((e||0)-s,0,"top",null,!1),indented:0,startOfLine:!0,prevToken:null}},token:function(e,t){var n=t.context;if(e.sol()&&(null==n.align&&(n.align=!1),t.indented=e.indentation(),t.startOfLine=!0),e.eatSpace())return N(e,t),null;i=l=null;var r,o=(t.tokenize||C)(e,t);if("comment"==o||"meta"==o)return o;if(null==n.align&&(n.align=!0),";"==i||":"==i||","==i&&e.match(/^\s*(?:\/\/.*)?$/,!1))for(;"statement"==t.context.type;)P(t);else if("{"==i)D(t,e.column(),"}");else if("["==i)D(t,e.column(),"]");else if("("==i)D(t,e.column(),")");else if("}"==i){for(;"statement"==n.type;)n=P(t);for("}"==n.type&&(n=P(t));"statement"==n.type;)n=P(t)}else i==n.type?P(t):k&&(("}"==n.type||"top"==n.type)&&";"!=i||"statement"==n.type&&"newstatement"==i)&&D(t,e.column(),"statement",e.current());return"variable"==o&&("def"==t.prevToken||a.typeFirstDefinitions&&E(e,t,e.start)&&z(t.context)&&e.match(/^\s*\(/,!1))&&(o="def"),"def"==(o=y.token&&void 0!==(r=y.token(e,t,o))?r:o)&&!1===a.styleDefs&&(o="variable"),t.startOfLine=!1,t.prevToken=l?"def":o||i,N(e,t),o},indent:function(e,t){if(e.tokenize!=C&&null!=e.tokenize||e.typeAtEndOfLine&&z(e.context))return M.Pass;var n=e.context,r=t&&t.charAt(0),o=r==n.type;if("statement"==n.type&&"}"==r&&(n=n.prev),a.dontIndentStatements)for(;"statement"==n.type&&a.dontIndentStatements.test(n.info);)n=n.prev;if(y.indent){e=y.indent(e,n,t,s);if("number"==typeof e)return e}e=n.prev&&"switch"==n.prev.info;if(a.allmanIndentation&&/[{(]/.test(r)){for(;"top"!=n.type&&"}"!=n.type;)n=n.prev;return n.indented}return"statement"==n.type?n.indented+("{"==r?0:c):!n.align||u&&")"==n.type?")"!=n.type||o?n.indented+(o?0:s)+(o||!e||/^(?:case|default)\b/.test(t)?0:s):n.indented+c:n.column+(o?0:1)},electricInput:e?/^\s*(?:case .*?:|default:|\{\}?|\})$/:/^\s*[{}]$/,blockCommentStart:"/*",blockCommentEnd:"*/",blockCommentContinue:" * ",lineComment:"//",fold:"brace"}});var t="auto if break case register continue return default do sizeof static else struct switch extern typedef union for goto while enum const volatile inline restrict asm fortran",n="alignas alignof and and_eq audit axiom bitand bitor catch class compl concept constexpr const_cast decltype delete dynamic_cast explicit export final friend import module mutable namespace new noexcept not not_eq operator or or_eq override private protected public reinterpret_cast requLine truncated
|
||||||
|
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror"),require("../htmlmixed/htmlmixed"),require("../clike/clike")):"function"==typeof define&&define.amd?define(["../../lib/codemirror","../htmlmixed/htmlmixed","../clike/clike"],e):e(CodeMirror)}(function(a){"use strict";function e(e){for(var t={},_=e.split(" "),r=0;r<_.length;++r)t[_[r]]=!0;return t}function o(s,i,l){return 0==s.length?n(i):function(e,t){for(var _=s[0],r=0;r<_.length;r++)if(e.match(_[r][0]))return t.tokenize=o(s.slice(1),i),_[r][1];return t.tokenize=n(i,l),"string"}}function n(n,a){return function(e,t){var _=e,r=t,s=n,i=a;if(!1!==i&&_.match("${",!1)||_.match("{$",!1))return r.tokenize=null,"string";if(!1!==i&&_.match(/^\$[a-zA-Z_][a-zA-Z0-9_]*/))return _.match("[",!1)&&(r.tokenize=o([[["[",null]],[[/\d[\w\.]*/,"number"],[/\$[a-zA-Z_][a-zA-Z0-9_]*/,"variable-2"],[/[\w\$]+/,"variable"]],[["]",null]]],s,i)),_.match(/^->\w/,!1)&&(r.tokenize=o([[["->",null]],[[/[\w]+/,"variable"]]],s,i)),"variable-2";for(var l=!1;!_.eol()&&(l||!1===i||!_.match("{$",!1)&&!_.match(/^(\$[a-zA-Z_][a-zA-Z0-9_]*|\$\{)/,!1));){if(!l&&_.match(s)){r.tokenize=null,r.tokStack.pop(),r.tokStack.pop();break}l="\\"==_.next()&&!l}return"string"}}var t="abstract and array as break case catch class clone const continue declare default do else elseif enddeclare endfor endforeach endif endswitch endwhile enum extends final for foreach function global goto if implements interface instanceof namespace new or private protected public static switch throw trait try use var while xor die echo empty exit eval include include_once isset list require require_once return print unset __halt_compiler self static parent yield insteadof finally readonly match",_="true false null TRUE FALSE NULL __CLASS__ __DIR__ __FILE__ __LINE__ __METHOD__ __FUNCTION__ __NAMESPACE__ __TRAIT__",r="func_num_args func_get_arg func_get_args strlen strcmp strncmp strcasecmp strncasecmp each error_reporting define defined trigger_error user_error set_error_handler restore_error_handler get_declared_classes get_loaded_extensions extension_loaded get_extension_funcs debug_backtrace constant bin2hex hex2bin sleep usleep time mktime gmmktime strftime gmstrftime strtotime date gmdate getdate localtime checkdate flush wordwrap htmlspecialchars htmlentities html_entity_decode md5 md5_file crc32 getimagesize image_type_to_mime_type phpinfo phpversion phpcredits strnatcmp strnatcasecmp substr_count strspn strcspn strtok strtoupper strtolower strpos strrpos strrev hebrev hebrevc nl2br basename dirname pathinfo stripslashes stripcslashes strstr stristr strrchr str_shuffle str_word_count strcoll substr substr_replace quotemeta ucfirst ucwords strtr addslashes addcslashes rtrim str_replace str_repeat count_chars chunk_split trim ltrim strip_tags similar_text explode implode setlocale localeconv parse_str str_pad chop strchr sprintf printf vprintf vsprintf sscanf fscanf parse_url urlencode urldecode rawurlencode rawurldecode readlink linkinfo link unlink exec system escapeshellcmd escapeshellarg passthru shell_exec proc_open proc_close rand srand getrandmax mt_rand mt_srand mt_getrandmax base64_decode base64_encode abs ceil floor round is_finite is_nan is_infinite bindec hexdec octdec decbin decoct dechex base_convert number_format fmod ip2long long2ip getenv putenv getopt microtime gettimeofday getrusage uniqid quoted_printable_decode set_time_limit get_cfg_var magic_quotes_runtime set_magic_quotes_runtime get_magic_quotes_gpc get_magic_quotes_runtime import_request_variables error_log serialize unserialize memory_get_usage memory_get_peak_usage var_dump var_export debug_zval_dump print_r highlight_file show_source highlight_string ini_get ini_get_all ini_set ini_alter ini_restore get_include_path set_include_path restore_include_path setcookie header headers_sent connection_aborted connection_status ignore_user_abort parse_ini_file is_uploaded_file move_uploaded_file intval floatval doubleval strval gettype settype is_null is_resource is_bool is_long is_float is_int is_integer is_double is_real is_numeric is_string is_array is_object is_scalar ereg ereg_replace eregi eregi_replace split spliti join sql_regcase dl pclose popen readfile rewind rmdir umask fclose feof fgetc fgets fgetss fread fopen fpassthru ftruncate fstat fseek ftell fflush fwrite fputs mkdir rename copy tempnam tmpfile file file_get_contents file_put_contents stream_select stream_context_create stream_context_set_params stream_context_set_option stream_context_get_options stream_filter_prepend stream_filter_append fgetcsv flock get_meta_tags stream_set_write_buffer set_file_buffer set_socket_blocking stream_set_blocking socket_set_blocking stream_get_meta_data stream_register_wrapper stream_wrapper_register stream_set_timeout socket_set_timeout socket_get_status realpath fnmatch fsockopen pfsockopen pack unpack get_browser crypt opendir closedir chdir getcwd rewinddir readdir dir glob fileatime filectime filegroup fileiLine truncated
|
||||||
|
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(x){"use strict";function k(e){return new RegExp("^(("+e.join(")|(")+"))\\b")}var _=k(["and","or","not","is"]),v=["as","assert","break","class","continue","def","del","elif","else","except","finally","for","from","global","if","import","lambda","pass","raise","return","try","while","with","yield","in","False","True"],z=["abs","all","any","bin","bool","bytearray","callable","chr","classmethod","compile","complex","delattr","dict","dir","divmod","enumerate","eval","filter","float","format","frozenset","getattr","globals","hasattr","hash","help","hex","id","input","int","isinstance","issubclass","iter","len","list","locals","map","max","memoryview","min","next","object","oct","open","ord","pow","property","range","repr","reversed","round","set","setattr","slice","sorted","staticmethod","str","sum","super","tuple","type","vars","zip","__import__","NotImplemented","Ellipsis","__debug__"];function w(e){return e.scopes[e.scopes.length-1]}x.registerHelper("hintWords","python",v.concat(z).concat(["exec","print"])),x.defineMode("python",function(t,l){for(var s="error",o=l.delimiters||l.singleDelimiters||/^[\(\)\[\]\{\}@,:`=;\.\\]/,a=[l.singleOperators,l.doubleOperators,l.doubleDelimiters,l.tripleDelimiters,l.operators||/^([-+*/%\/&|^]=?|[<>=]+|\/\/=?|\*\*=?|!=|[~!@]|\.\.\.)/],e=0;e<a.length;e++)a[e]||a.splice(e--,1);var c,u,f=l.hangingIndent||t.indentUnit,n=v,r=z,p=(null!=l.extra_keywords&&(n=n.concat(l.extra_keywords)),null!=l.extra_builtins&&(r=r.concat(l.extra_builtins)),!(l.version&&Number(l.version)<3)),d=(u=p?(c=l.identifiers||/^[_A-Za-z\u00A1-\uFFFF][_A-Za-z0-9\u00A1-\uFFFF]*/,n=n.concat(["nonlocal","None","aiter","anext","async","await","breakpoint","match","case"]),r=r.concat(["ascii","bytes","exec","print"]),new RegExp("^(([rbuf]|(br)|(rb)|(fr)|(rf))?('{3}|\"{3}|['\"]))","i")):(c=l.identifiers||/^[_A-Za-z][_A-Za-z0-9]*/,n=n.concat(["exec","print"]),r=r.concat(["apply","basestring","buffer","cmp","coerce","execfile","file","intern","long","raw_input","reduce","reload","unichr","unicode","xrange","None"]),new RegExp("^(([rubf]|(ur)|(br))?('{3}|\"{3}|['\"]))","i")),k(n)),m=k(r);function i(e,t){var n,r=e.sol()&&"\\"!=t.lastToken;return r&&(t.indent=e.indentation()),r&&"py"==w(t).type?(r=w(t).offset,e.eatSpace()?(r<(n=e.indentation())?b(t):n<r&&y(e,t)&&"#"!=e.peek()&&(t.errorToken=!0),null):(n=h(e,t),0<r&&y(e,t)&&(n+=" "+s),n)):h(e,t)}function h(e,t,n){if(e.eatSpace())return null;if(!n&&e.match(/^#.*/))return"comment";if(e.match(/^[0-9\.]/,!1)){var r=!1;if(e.match(/^[\d_]*\.\d+(e[\+\-]?\d+)?/i)&&(r=!0),e.match(/^[\d_]+\.\d*/)&&(r=!0),r=e.match(/^\.\d+/)?!0:r)return e.eat(/J/i),"number";r=!1;if(e.match(/^0x[0-9a-f_]+/i)&&(r=!0),e.match(/^0b[01_]+/i)&&(r=!0),e.match(/^0o[0-7_]+/i)&&(r=!0),e.match(/^[1-9][\d_]*(e[\+\-]?[\d_]+)?/)&&(e.eat(/J/i),r=!0),r=e.match(/^0(?![\dx])/i)?!0:r)return e.eat(/L/i),"number"}if(e.match(u))return r=-1!==e.current().toLowerCase().indexOf("f"),t.tokenize=(r?function(n,r){for(;0<="rubf".indexOf(n.charAt(0).toLowerCase());)n=n.substr(1);var i=1==n.length,o="string";function a(e,t){for(;!e.eol();)if(e.eatWhile(/[^'"\{\}\\]/),e.eat("\\")){if(e.next(),i&&e.eol())return o}else{if(e.match(n))return t.tokenize=r,o;if(e.match("{{"))return o;if(e.match("{",!1))return t.tokenize=function r(i){return function(e,t){var n=h(e,t,!0);return"punctuation"==n&&("{"==e.current()?t.tokenize=r(i+1):"}"==e.current()&&(t.tokenize=1<i?r(i-1):a)),n}}(0),e.current()?o:t.tokenize(e,t);if(e.match("}}"))return o;if(e.match("}"))return s;e.eat(/['"]/)}if(i){if(l.singleLineStringErrors)return s;t.tokenize=r}return o}return a.isString=!0,a}:function(n,r){for(;0<="rubf".indexOf(n.charAt(0).toLowerCase());)n=n.substr(1);var i=1==n.length,o="string";function e(e,t){for(;!e.eol();)if(e.eatWhile(/[^'"\\]/),e.eat("\\")){if(e.next(),i&&e.eol())return o}else{if(e.match(n))return t.tokenize=r,o;e.eat(/['"]/)}if(i){if(l.singleLineStringErrors)return s;t.tokenize=r}return o}return e.isString=!0,e})(e.current(),t.tokenize),t.tokenize(e,t);for(var i=0;i<a.length;i++)if(e.match(a[i]))return"operator";return e.match(o)?"punctuation":"."==t.lastToken&&e.match(c)?"property":e.match(d)||e.match(_)?"keyword":e.match(m)?"builtin":e.match(/^(self|cls)\b/)?"variable-2":e.match(c)?"def"==t.lastToken||"class"==t.lastToken?"def":"variable":(e.next(),n?null:s)}function b(e){for(;"py"!=w(e).type;)e.scopes.pop();e.scopes.push({offset:w(e).offset+t.indentUnit,type:"py",align:null})}function y(e,t){for(var n=e.indentation();1<t.scopes.length&&w(t).offset>n;){if("py"!=w(t).type)return 1;t.scopes.pop()}return w(t).offset!=n}function g(e,t){e.sol()&&(t.beginningOfLine=!0,t.dedent=!1);var n,r,i,o=t.tokenize(e,t),a=e.current();if(t.beginningOfLine&&"@"==a)return e.match(c,!1)?"meta":p?"operator":s;if(/\S/.test(a)&&(t.beginningOfLine=!1),"variable"!=o&&"builtin"Line truncated
|
||||||
|
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(p){"use strict";p.defineMode("go",function(e){var i,o=e.indentUnit,r={break:!0,case:!0,chan:!0,const:!0,continue:!0,default:!0,defer:!0,else:!0,fallthrough:!0,for:!0,func:!0,go:!0,goto:!0,if:!0,import:!0,interface:!0,map:!0,package:!0,range:!0,return:!0,select:!0,struct:!0,switch:!0,type:!0,var:!0,bool:!0,byte:!0,complex64:!0,complex128:!0,float32:!0,float64:!0,int8:!0,int16:!0,int32:!0,int64:!0,string:!0,uint8:!0,uint16:!0,uint32:!0,uint64:!0,int:!0,uint:!0,uintptr:!0,error:!0,rune:!0,any:!0,comparable:!0},a={true:!0,false:!0,iota:!0,nil:!0,append:!0,cap:!0,close:!0,complex:!0,copy:!0,delete:!0,imag:!0,len:!0,make:!0,new:!0,panic:!0,print:!0,println:!0,real:!0,recover:!0},c=/[+\-*&^%:=<>!|\/]/;function u(e,t){var o,n=e.next();if('"'==n||"'"==n||"`"==n)return t.tokenize=(o=n,function(e,t){for(var n,r=!1,i=!1;null!=(n=e.next());){if(n==o&&!r){i=!0;break}r=!r&&"`"!=o&&"\\"==n}return(i||!r&&"`"!=o)&&(t.tokenize=u),"string"}),t.tokenize(e,t);if(/[\d\.]/.test(n))return"."==n?e.match(/^[0-9_]+([eE][\-+]?[0-9_]+)?/):"0"==n?e.match(/^[xX][0-9a-fA-F_]+/)||e.match(/^[0-7_]+/):e.match(/^[0-9_]*\.?[0-9_]*([eE][\-+]?[0-9_]+)?/),"number";if(/[\[\]{}\(\),;\:\.]/.test(n))return i=n,null;if("/"==n){if(e.eat("*"))return(t.tokenize=l)(e,t);if(e.eat("/"))return e.skipToEnd(),"comment"}if(c.test(n))return e.eatWhile(c),"operator";e.eatWhile(/[\w\$_\xa1-\uffff]/);t=e.current();return r.propertyIsEnumerable(t)?("case"!=t&&"default"!=t||(i="case"),"keyword"):a.propertyIsEnumerable(t)?"atom":"variable"}function l(e,t){for(var n,r=!1;n=e.next();){if("/"==n&&r){t.tokenize=u;break}r="*"==n}return"comment"}function f(e,t,n,r,i){this.indented=e,this.column=t,this.type=n,this.align=r,this.prev=i}function s(e,t,n){e.context=new f(e.indented,t,n,null,e.context)}function d(e){var t;e.context.prev&&(")"!=(t=e.context.type)&&"]"!=t&&"}"!=t||(e.indented=e.context.indented),e.context=e.context.prev)}return{startState:function(e){return{tokenize:null,context:new f((e||0)-o,0,"top",!1),indented:0,startOfLine:!0}},token:function(e,t){var n=t.context;if(e.sol()&&(null==n.align&&(n.align=!1),t.indented=e.indentation(),t.startOfLine=!0,"case"==n.type&&(n.type="}")),e.eatSpace())return null;i=null;var r=(t.tokenize||u)(e,t);return"comment"==r||(null==n.align&&(n.align=!0),"{"==i?s(t,e.column(),"}"):"["==i?s(t,e.column(),"]"):"("==i?s(t,e.column(),")"):"case"==i?n.type="case":("}"==i&&"}"==n.type||i==n.type)&&d(t),t.startOfLine=!1),r},indent:function(e,t){if(e.tokenize!=u&&null!=e.tokenize)return p.Pass;var n=e.context,r=t&&t.charAt(0);if("case"==n.type&&/^(?:case|default)\b/.test(t))return e.context.type="}",n.indented;t=r==n.type;return n.align?n.column+(t?0:1):n.indented+(t?0:o)},electricChars:"{}):",closeBrackets:"()[]{}''\"\"``",fold:"brace",blockCommentStart:"/*",blockCommentEnd:"*/",lineComment:"//"}}),p.defineMIME("text/x-go","go")});;
|
||||||
|
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(e){"use strict";e.defineMode("yaml",function(){var n=new RegExp("\\b(("+["true","false","on","off","yes","no"].join(")|(")+"))$","i");return{token:function(e,i){var t=e.peek(),r=i.escaped;if(i.escaped=!1,"#"==t&&(0==e.pos||/\s/.test(e.string.charAt(e.pos-1))))return e.skipToEnd(),"comment";if(e.match(/^('([^']|\\.)*'?|"([^"]|\\.)*"?)/))return"string";if(i.literal&&e.indentation()>i.keyCol)return e.skipToEnd(),"string";if(i.literal&&(i.literal=!1),e.sol()){if(i.keyCol=0,i.pair=!1,i.pairStart=!1,e.match("---"))return"def";if(e.match("..."))return"def";if(e.match(/\s*-\s+/))return"meta"}if(e.match(/^(\{|\}|\[|\])/))return"{"==t?i.inlinePairs++:"}"==t?i.inlinePairs--:"["==t?i.inlineList++:i.inlineList--,"meta";if(0<i.inlineList&&!r&&","==t)return e.next(),"meta";if(0<i.inlinePairs&&!r&&","==t)return i.keyCol=0,i.pair=!1,i.pairStart=!1,e.next(),"meta";if(i.pairStart){if(e.match(/^\s*(\||\>)\s*/))return i.literal=!0,"meta";if(e.match(/^\s*(\&|\*)[a-z0-9\._-]+\b/i))return"variable-2";if(0==i.inlinePairs&&e.match(/^\s*-?[0-9\.\,]+\s?$/))return"number";if(0<i.inlinePairs&&e.match(/^\s*-?[0-9\.\,]+\s?(?=(,|}))/))return"number";if(e.match(n))return"keyword"}return!i.pair&&e.match(/^\s*(?:[,\[\]{}&*!|>'"%@`][^\s'":]|[^\s,\[\]{}#&*!|>'"%@`])[^#:]*(?=:($|\s))/)?(i.pair=!0,i.keyCol=e.indentation(),"atom"):i.pair&&e.match(/^:\s*/)?(i.pairStart=!0,"meta"):(i.pairStart=!1,i.escaped="\\"==t,e.next(),null)},startState:function(){return{pair:!1,pairStart:!1,keyCol:0,inlinePairs:0,inlineList:0,literal:!1,escaped:!1}},lineComment:"#",fold:"indent"}}),e.defineMIME("text/x-yaml","yaml"),e.defineMIME("text/yaml","yaml")});;
|
||||||
|
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(s){"use strict";s.defineMode("shell",function(){var o={};function e(e,t){for(var n=0;n<t.length;n++)o[t[n]]=e}var t=["true","false"],n=["if","then","do","else","elif","while","until","for","in","esac","fi","fin","fil","done","exit","set","unset","export","function"],r=["ab","awk","bash","beep","cat","cc","cd","chown","chmod","chroot","clear","cp","curl","cut","diff","echo","find","gawk","gcc","get","git","grep","hg","kill","killall","ln","ls","make","mkdir","openssl","mv","nc","nl","node","npm","ping","ps","restart","rm","rmdir","sed","service","sh","shopt","shred","source","sort","sleep","ssh","start","stop","su","sudo","svn","tee","telnet","top","touch","vi","vim","wall","wc","wget","who","write","yes","zsh"];function i(e,t){if(e.eatSpace())return null;var n,r=e.sol(),i=e.next();if("\\"===i)return e.next(),null;if("'"===i||'"'===i||"`"===i)return t.tokens.unshift(f(i,"`"===i?"quote":"string")),l(e,t);if("#"===i)return r&&e.eat("!")?(e.skipToEnd(),"meta"):(e.skipToEnd(),"comment");if("$"===i)return t.tokens.unshift(u),l(e,t);if("+"===i||"="===i)return"operator";if("-"===i)return e.eat("-"),e.eatWhile(/\w/),"attribute";if("<"==i){if(e.match("<<"))return"operator";r=e.match(/^<-?\s*['"]?([^'"]*)['"]?/);if(r)return t.tokens.unshift((n=r[1],function(e,t){return e.sol()&&e.string==n&&t.tokens.shift(),e.skipToEnd(),"string-2"})),"string-2"}if(/\d/.test(i)&&(e.eatWhile(/\d/),e.eol()||!/\w/.test(e.peek())))return"number";e.eatWhile(/[\w-]/);t=e.current();return"="===e.peek()&&/\w+/.test(t)?"def":o.hasOwnProperty(t)?o[t]:null}function f(i,o){var s="("==i?")":"{"==i?"}":i;return function(e,t){for(var n,r=!1;null!=(n=e.next());){if(n===s&&!r){t.tokens.shift();break}if("$"===n&&!r&&"'"!==i&&e.peek()!=s){r=!0,e.backUp(1),t.tokens.unshift(u);break}if(!r&&i!==s&&n===i)return t.tokens.unshift(f(i,o)),l(e,t);if(!r&&/['"]/.test(n)&&!/['"]/.test(i)){t.tokens.unshift(function(n,r){return function(e,t){return t.tokens[0]=f(n,r),e.next(),l(e,t)}}(n,"string")),e.backUp(1);break}r=!r&&"\\"===n}return o}}s.registerHelper("hintWords","shell",t.concat(n,r)),e("atom",t),e("keyword",n),e("builtin",r);var u=function(e,t){1<t.tokens.length&&e.eat("$");var n=e.next();return/['"({]/.test(n)?(t.tokens[0]=f(n,"("==n?"quote":"{"==n?"def":"string"),l(e,t)):(/\d/.test(n)||e.eatWhile(/\w/),t.tokens.shift(),"def")};function l(e,t){return(t.tokens[0]||i)(e,t)}return{startState:function(){return{tokens:[]}},token:l,closeBrackets:"()[]{}''\"\"``",lineComment:"#",fold:"brace"}}),s.defineMIME("text/x-sh","shell"),s.defineMIME("application/x-sh","shell")});;
|
||||||
|
!function(t){"object"==typeof exports&&"object"==typeof module?t(require("../../lib/codemirror"),require("../xml/xml"),require("../meta")):"function"==typeof define&&define.amd?define(["../../lib/codemirror","../xml/xml","../meta"],t):t(CodeMirror)}(function(w){"use strict";w.defineMode("markdown",function(m,d){var g=w.getMode(m,"text/html"),u="null"==g.name;void 0===d.highlightFormatting&&(d.highlightFormatting=!1),void 0===d.maxBlockquoteDepth&&(d.maxBlockquoteDepth=0),void 0===d.taskLists&&(d.taskLists=!1),void 0===d.strikethrough&&(d.strikethrough=!1),void 0===d.emoji&&(d.emoji=!1),void 0===d.fencedCodeBlockHighlighting&&(d.fencedCodeBlockHighlighting=!0),void 0===d.fencedCodeBlockDefaultMode&&(d.fencedCodeBlockDefaultMode="text/plain"),void 0===d.xml&&(d.xml=!0),void 0===d.tokenTypeOverrides&&(d.tokenTypeOverrides={});var t,c={header:"header",code:"comment",quote:"quote",list1:"variable-2",list2:"variable-3",list3:"keyword",hr:"hr",image:"image",imageAltText:"image-alt-text",imageMarker:"image-marker",formatting:"formatting",linkInline:"link",linkEmail:"link",linkText:"link",linkHref:"string",em:"em",strong:"strong",strikethrough:"strikethrough",emoji:"builtin"};for(t in c)c.hasOwnProperty(t)&&d.tokenTypeOverrides[t]&&(c[t]=d.tokenTypeOverrides[t]);var f=/^([*\-_])(?:\s*\1){2,}\s*$/,k=/^(?:[*\-+]|^[0-9]+([.)]))\s+/,F=/^\[(x| )\](?=\s)/i,D=d.allowAtxHeaderWithoutSpace?/^(#+)/:/^(#+)(?: |$)/,p=/^ {0,3}(?:\={1,}|-{2,})\s*$/,i=/^[^#!\[\]*_\\<>` "'(~:]+/,E=/^(~~~+|```+)[ \t]*([\w\/+#-]*)[^\n`]*$/,x=/^\s*\[[^\]]+?\]:.*$/,A=/[!"#$%&'()*+,\-.\/:;<=>?@\[\\\]^_`{|}~\xA1\xA7\xAB\xB6\xB7\xBB\xBF\u037E\u0387\u055A-\u055F\u0589\u058A\u05BE\u05C0\u05C3\u05C6\u05F3\u05F4\u0609\u060A\u060C\u060D\u061B\u061E\u061F\u066A-\u066D\u06D4\u0700-\u070D\u07F7-\u07F9\u0830-\u083E\u085E\u0964\u0965\u0970\u0AF0\u0DF4\u0E4F\u0E5A\u0E5B\u0F04-\u0F12\u0F14\u0F3A-\u0F3D\u0F85\u0FD0-\u0FD4\u0FD9\u0FDA\u104A-\u104F\u10FB\u1360-\u1368\u1400\u166D\u166E\u169B\u169C\u16EB-\u16ED\u1735\u1736\u17D4-\u17D6\u17D8-\u17DA\u1800-\u180A\u1944\u1945\u1A1E\u1A1F\u1AA0-\u1AA6\u1AA8-\u1AAD\u1B5A-\u1B60\u1BFC-\u1BFF\u1C3B-\u1C3F\u1C7E\u1C7F\u1CC0-\u1CC7\u1CD3\u2010-\u2027\u2030-\u2043\u2045-\u2051\u2053-\u205E\u207D\u207E\u208D\u208E\u2308-\u230B\u2329\u232A\u2768-\u2775\u27C5\u27C6\u27E6-\u27EF\u2983-\u2998\u29D8-\u29DB\u29FC\u29FD\u2CF9-\u2CFC\u2CFE\u2CFF\u2D70\u2E00-\u2E2E\u2E30-\u2E42\u3001-\u3003\u3008-\u3011\u3014-\u301F\u3030\u303D\u30A0\u30FB\uA4FE\uA4FF\uA60D-\uA60F\uA673\uA67E\uA6F2-\uA6F7\uA874-\uA877\uA8CE\uA8CF\uA8F8-\uA8FA\uA8FC\uA92E\uA92F\uA95F\uA9C1-\uA9CD\uA9DE\uA9DF\uAA5C-\uAA5F\uAADE\uAADF\uAAF0\uAAF1\uABEB\uFD3E\uFD3F\uFE10-\uFE19\uFE30-\uFE52\uFE54-\uFE61\uFE63\uFE68\uFE6A\uFE6B\uFF01-\uFF03\uFF05-\uFF0A\uFF0C-\uFF0F\uFF1A\uFF1B\uFF1F\uFF20\uFF3B-\uFF3D\uFF3F\uFF5B\uFF5D\uFF5F-\uFF65]|\uD800[\uDD00-\uDD02\uDF9F\uDFD0]|\uD801\uDD6F|\uD802[\uDC57\uDD1F\uDD3F\uDE50-\uDE58\uDE7F\uDEF0-\uDEF6\uDF39-\uDF3F\uDF99-\uDF9C]|\uD804[\uDC47-\uDC4D\uDCBB\uDCBC\uDCBE-\uDCC1\uDD40-\uDD43\uDD74\uDD75\uDDC5-\uDDC9\uDDCD\uDDDB\uDDDD-\uDDDF\uDE38-\uDE3D\uDEA9]|\uD805[\uDCC6\uDDC1-\uDDD7\uDE41-\uDE43\uDF3C-\uDF3E]|\uD809[\uDC70-\uDC74]|\uD81A[\uDE6E\uDE6F\uDEF5\uDF37-\uDF3B\uDF44]|\uD82F\uDC9F|\uD836[\uDE87-\uDE8B]/;function C(t,e,i){return(e.f=e.inline=i)(t,e)}function S(t,e,i){return(e.f=e.block=i)(t,e)}function n(t){var e;return t.linkTitle=!1,t.linkHref=!1,t.linkText=!1,t.em=!1,t.strong=!1,t.strikethrough=!1,t.quote=0,t.indentedCode=!1,t.f==v&&(u?u:"xml"==(e=w.innerMode(g,t.htmlState)).mode.name&&null===e.state.tagStart&&!e.state.context&&e.state.tokenize.isInText)&&(t.f=T,t.block=a,t.htmlState=null),t.trailingSpace=0,t.trailingSpaceNewLine=!1,t.prevLine=t.thisLine,t.thisLine={stream:null},null}function a(t,e){var i=t.column()===e.indentation,n=!(n=e.prevLine.stream)||!/\S/.test(n.string),u=e.indentedCode,a=e.prevLine.hr,r=!1!==e.list,o=(e.listStack[e.listStack.length-1]||0)+3,l=(e.indentedCode=!1,e.indentation);if(null===e.indentationDiff&&(e.indentationDiff=e.indentation,r)){for(e.list=null;l<e.listStack[e.listStack.length-1];)e.listStack.pop(),e.listStack.length?e.indentation=e.listStack[e.listStack.length-1]:e.list=!1;!1!==e.list&&(e.indentationDiff=l-e.listStack[e.listStack.length-1])}var h,s=!(n||a||e.prevLine.header||r&&u||e.prevLine.fencedCodeEnd),a=(!1===e.list||a||n)&&e.indentation<=o&&t.match(f),g=null;return 4<=e.indentationDiff&&(u||e.prevLine.fencedCodeEnd||e.prevLine.header||n)?(t.skipToEnd(),e.indentedCode=!0,c.code):t.eatSpace()?null:i&&e.indentation<=o&&(g=t.match(D))&&g[1].length<=6?(e.quote=0,e.header=g[1].length,e.thisLine.header=!0,d.highlightFormatting&&(e.formatting="header"),e.f=e.inline,L(e)):e.indentation<=o&&t.eat(">")?(e.quote=i?1:e.quote+1,d.highlightFormatting&&(e.formatting="quote"),t.eatSpace(),L(e)):!a&&!e.setext&&i&&e.indentation<=o&&(g=t.match(k))?(u=g[1]?"ol":"ul",e.indentation=l+t.current().length,e.list=!0,e.quote=0,e.listStack.push(e.indentation),e.em=!1,e.strong=!1,e.code=!1,e.strikethrough=!1,d.taskLists&Line truncated
|
||||||
|
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(a){"use strict";function e(e){for(var t;null!=(t=e.next());)if("`"==t&&!e.eat("`"))return"variable-2";return e.backUp(e.current().length-1),e.eatWhile(/\w/)?"variable-2":null}function t(e){return e.eat("@")&&(e.match("session."),e.match("local."),e.match("global.")),e.eat("'")?(e.match(/^.*'/),"variable-2"):e.eat('"')?(e.match(/^.*"/),"variable-2"):e.eat("`")?(e.match(/^.*`/),"variable-2"):e.match(/^[0-9a-zA-Z$\.\_]+/)?"variable-2":null}function r(e){return e.eat("N")?"atom":e.match(/^[a-zA-Z.#!?]/)?"variable-2":null}a.defineMode("sql",function(r,e){var i=e.client||{},n=e.atoms||{false:!0,true:!0,null:!0},s=e.builtin||y(v),o=e.keywords||y(f),l=e.operatorChars||/^[*+\-%<>!=&|~^\/]/,c=e.support||{},m=e.hooks||{},d=e.dateSQL||{date:!0,time:!0,timestamp:!0},u=!1!==e.backslashStringEscapes,p=e.brackets||/^[\{}\(\)\[\]]/,_=e.punctuation||/^[;.,:]/;function g(e,t){var r=e.next();if(m[r]){var a=m[r](e,t);if(!1!==a)return a}return c.hexNumber&&("0"==r&&e.match(/^[xX][0-9a-fA-F]+/)||("x"==r||"X"==r)&&e.match(/^'[0-9a-fA-F]*'/))||c.binaryNumber&&(("b"==r||"B"==r)&&e.match(/^'[01]*'/)||"0"==r&&e.match(/^b[01]+/))?"number":47<r.charCodeAt(0)&&r.charCodeAt(0)<58?(e.match(/^[0-9]*(\.[0-9]+)?([eE][-+]?[0-9]+)?/),c.decimallessFloat&&e.match(/^\.(?!\.)/),"number"):"?"==r&&(e.eatSpace()||e.eol()||e.eat(";"))?"variable-3":"'"==r||'"'==r&&c.doubleQuote?(t.tokenize=h(r),t.tokenize(e,t)):!(c.nCharCast&&("n"==r||"N"==r)||c.charsetCast&&"_"==r&&e.match(/[a-z][a-z0-9]*/i))||"'"!=e.peek()&&'"'!=e.peek()?c.escapeConstant&&("e"==r||"E"==r)&&("'"==e.peek()||'"'==e.peek()&&c.doubleQuote)?(t.tokenize=function(e,t){return(t.tokenize=h(e.next(),!0))(e,t)},"keyword"):c.commentSlashSlash&&"/"==r&&e.eat("/")||c.commentHash&&"#"==r||"-"==r&&e.eat("-")&&(!c.commentSpaceRequired||e.eat(" "))?(e.skipToEnd(),"comment"):"/"==r&&e.eat("*")?(t.tokenize=function a(i){return function(e,t){var r=e.match(/^.*?(\/\*|\*\/)/);return r?"/*"==r[1]?t.tokenize=a(i+1):t.tokenize=1<i?a(i-1):g:e.skipToEnd(),"comment"}}(1),t.tokenize(e,t)):"."==r?c.zerolessFloat&&e.match(/^(?:\d+(?:e[+-]?\d+)?)/i)?"number":e.match(/^\.+/)?null:e.match(/^[\w\d_$#]+/)?"variable-2":void 0:l.test(r)?(e.eatWhile(l),"operator"):p.test(r)?"bracket":_.test(r)?(e.eatWhile(_),"punctuation"):"{"==r&&(e.match(/^( )*(d|D|t|T|ts|TS)( )*'[^']*'( )*}/)||e.match(/^( )*(d|D|t|T|ts|TS)( )*"[^"]*"( )*}/))?"number":(e.eatWhile(/^[_\w\d]/),a=e.current().toLowerCase(),d.hasOwnProperty(a)&&(e.match(/^( )+'[^']*'/)||e.match(/^( )+"[^"]*"/))?"number":n.hasOwnProperty(a)?"atom":s.hasOwnProperty(a)?"type":o.hasOwnProperty(a)?"keyword":i.hasOwnProperty(a)?"builtin":null):"keyword"}function h(i,n){return function(e,t){for(var r,a=!1;null!=(r=e.next());){if(r==i&&!a){t.tokenize=g;break}a=(u||n)&&!a&&"\\"==r}return"string"}}function b(e,t,r){t.context={prev:t.context,indent:e.indentation(),col:e.column(),type:r}}return{startState:function(){return{tokenize:g,context:null}},token:function(e,t){if(e.sol()&&t.context&&null==t.context.align&&(t.context.align=!1),t.tokenize==g&&e.eatSpace())return null;var r=t.tokenize(e,t);if("comment"==r)return r;t.context&&null==t.context.align&&(t.context.align=!0);var a=e.current();return"("==a?b(e,t,")"):"["==a?b(e,t,"]"):t.context&&t.context.type==a&&((e=t).indent=e.context.indent,e.context=e.context.prev),r},indent:function(e,t){e=e.context;if(!e)return a.Pass;t=t.charAt(0)==e.type;return e.align?e.col+(t?0:1):e.indent+(t?0:r.indentUnit)},blockCommentStart:"/*",blockCommentEnd:"*/",lineComment:c.commentSlashSlash?"//":c.commentHash?"#":"--",closeBrackets:"()[]{}''\"\"``",config:e}});var f="alter and as asc between by count create delete desc distinct drop from group having in insert into is join like not on or order select set table union update values where limit ";function y(e){for(var t={},r=e.split(" "),a=0;a<r.length;++a)t[r[a]]=!0;return t}var v="bool boolean bit blob enum long longblob longtext medium mediumblob mediumint mediumtext time timestamp tinyblob tinyint tinytext text bigint int int1 int2 int3 int4 int8 integer float float4 float8 double char varbinary varchar varcharacter precision real date datetime year unsigned signed decimal numeric";a.defineMIME("text/x-sql",{name:"sql",keywords:y(f+"begin"),builtin:y(v),atoms:y("false true null unknown"),dateSQL:y("date time timestamp"),support:y("doubleQuote binaryNumber hexNumber")}),a.defineMIME("text/x-mssql",{name:"sql",client:y("$partition binary_checksum checksum connectionproperty context_info current_request_id error_line error_message error_number error_procedure error_severity error_state formatmessage get_filestream_transaction_context getansinull host_id host_name isnull isnumeric min_active_rowversion newid newsequentialid rowcount_big xact_state object_id"),keywords:y(f+"begin trigger proc view index for add constLine truncated
|
||||||
|
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(e){"use strict";e.defineMode("properties",function(){return{token:function(e,i){var t=e.sol()||i.afterSection,n=e.eol();if(i.afterSection=!1,t&&(i.nextMultiline?(i.inMultiline=!0,i.nextMultiline=!1):i.position="def"),n&&!i.nextMultiline&&(i.inMultiline=!1,i.position="def"),t)for(;e.eatSpace(););n=e.next();return!t||"#"!==n&&"!"!==n&&";"!==n?t&&"["===n?(i.afterSection=!0,e.skipTo("]"),e.eat("]"),"header"):"="===n||":"===n?(i.position="quote",null):("\\"===n&&"quote"===i.position&&e.eol()&&(i.nextMultiline=!0),i.position):(i.position="comment",e.skipToEnd(),"comment")},startState:function(){return{position:"def",nextMultiline:!1,inMultiline:!1,afterSection:!1}}}}),e.defineMIME("text/x-properties","properties"),e.defineMIME("text/x-ini","properties")});;
|
||||||
|
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(e){"use strict";e.defineMode("toml",function(){return{startState:function(){return{inString:!1,stringType:"",lhs:!0,inArray:0}},token:function(e,t){if(t.inString||'"'!=e.peek()&&"'"!=e.peek()||(t.stringType=e.peek(),e.next(),t.inString=!0),e.sol()&&0===t.inArray&&(t.lhs=!0),t.inString){for(;t.inString&&!e.eol();)e.peek()===t.stringType?(e.next(),t.inString=!1):"\\"===e.peek()?(e.next(),e.next()):e.match(/^.[^\\\"\']*/);return t.lhs?"property string":"string"}return t.inArray&&"]"===e.peek()?(e.next(),t.inArray--,"bracket"):t.lhs&&"["===e.peek()&&e.skipTo("]")?(e.next(),"]"===e.peek()&&e.next(),"atom"):"#"===e.peek()?(e.skipToEnd(),"comment"):e.eatSpace()?null:t.lhs&&e.eatWhile(function(e){return"="!=e&&" "!=e})?"property":t.lhs&&"="===e.peek()?(e.next(),t.lhs=!1,null):(t.lhs||!e.match(/^\d\d\d\d[\d\-\:\.T]*Z/))&&(t.lhs||!e.match("true")&&!e.match("false"))?t.lhs||"["!==e.peek()?!t.lhs&&e.match(/^\-?\d+(?:\.\d+)?/)?"number":(e.eatSpace()||e.next(),null):(t.inArray++,e.next(),"bracket"):"atom"}}}),e.defineMIME("text/x-toml","toml")});;
|
||||||
|
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(e){"use strict";e.defineMode("nginx",function(e){function _(e){for(var _={},t=e.split(" "),i=0;i<t.length;++i)_[t[i]]=!0;return _}var s,i=_("break return rewrite set accept_mutex accept_mutex_delay access_log add_after_body add_before_body add_header addition_types aio alias allow ancient_browser ancient_browser_value auth_basic auth_basic_user_file auth_http auth_http_header auth_http_timeout autoindex autoindex_exact_size autoindex_localtime charset charset_types client_body_buffer_size client_body_in_file_only client_body_in_single_buffer client_body_temp_path client_body_timeout client_header_buffer_size client_header_timeout client_max_body_size connection_pool_size create_full_put_path daemon dav_access dav_methods debug_connection debug_points default_type degradation degrade deny devpoll_changes devpoll_events directio directio_alignment empty_gif env epoll_events error_log eventport_events expires fastcgi_bind fastcgi_buffer_size fastcgi_buffers fastcgi_busy_buffers_size fastcgi_cache fastcgi_cache_key fastcgi_cache_methods fastcgi_cache_min_uses fastcgi_cache_path fastcgi_cache_use_stale fastcgi_cache_valid fastcgi_catch_stderr fastcgi_connect_timeout fastcgi_hide_header fastcgi_ignore_client_abort fastcgi_ignore_headers fastcgi_index fastcgi_intercept_errors fastcgi_max_temp_file_size fastcgi_next_upstream fastcgi_param fastcgi_pass_header fastcgi_pass_request_body fastcgi_pass_request_headers fastcgi_read_timeout fastcgi_send_lowat fastcgi_send_timeout fastcgi_split_path_info fastcgi_store fastcgi_store_access fastcgi_temp_file_write_size fastcgi_temp_path fastcgi_upstream_fail_timeout fastcgi_upstream_max_fails flv geoip_city geoip_country google_perftools_profiles gzip gzip_buffers gzip_comp_level gzip_disable gzip_hash gzip_http_version gzip_min_length gzip_no_buffer gzip_proxied gzip_static gzip_types gzip_vary gzip_window if_modified_since ignore_invalid_headers image_filter image_filter_buffer image_filter_jpeg_quality image_filter_transparency imap_auth imap_capabilities imap_client_buffer index ip_hash keepalive_requests keepalive_timeout kqueue_changes kqueue_events large_client_header_buffers limit_conn limit_conn_log_level limit_rate limit_rate_after limit_req limit_req_log_level limit_req_zone limit_zone lingering_time lingering_timeout lock_file log_format log_not_found log_subrequest map_hash_bucket_size map_hash_max_size master_process memcached_bind memcached_buffer_size memcached_connect_timeout memcached_next_upstream memcached_read_timeout memcached_send_timeout memcached_upstream_fail_timeout memcached_upstream_max_fails merge_slashes min_delete_depth modern_browser modern_browser_value msie_padding msie_refresh multi_accept open_file_cache open_file_cache_errors open_file_cache_events open_file_cache_min_uses open_file_cache_valid open_log_file_cache output_buffers override_charset perl perl_modules perl_require perl_set pid pop3_auth pop3_capabilities port_in_redirect postpone_gzipping postpone_output protocol proxy proxy_bind proxy_buffer proxy_buffer_size proxy_buffering proxy_buffers proxy_busy_buffers_size proxy_cache proxy_cache_key proxy_cache_methods proxy_cache_min_uses proxy_cache_path proxy_cache_use_stale proxy_cache_valid proxy_connect_timeout proxy_headers_hash_bucket_size proxy_headers_hash_max_size proxy_hide_header proxy_ignore_client_abort proxy_ignore_headers proxy_intercept_errors proxy_max_temp_file_size proxy_method proxy_next_upstream proxy_pass_error_message proxy_pass_header proxy_pass_request_body proxy_pass_request_headers proxy_read_timeout proxy_redirect proxy_send_lowat proxy_send_timeout proxy_set_body proxy_set_header proxy_ssl_session_reuse proxy_store proxy_store_access proxy_temp_file_write_size proxy_temp_path proxy_timeout proxy_upstream_fail_timeout proxy_upstream_max_fails random_index read_ahead real_ip_header recursive_error_pages request_pool_size reset_timedout_connection resolver resolver_timeout rewrite_log rtsig_overflow_events rtsig_overflow_test rtsig_overflow_threshold rtsig_signo satisfy secure_link_secret send_lowat send_timeout sendfile sendfile_max_chunk server_name_in_redirect server_names_hash_bucket_size server_names_hash_max_size server_tokens set_real_ip_from smtp_auth smtp_capabilities smtp_client_buffer smtp_greeting_delay so_keepalive source_charset ssi ssi_ignore_recycled_buffers ssi_min_file_chunk ssi_silent_errors ssi_types ssi_value_length ssl ssl_certificate ssl_certificate_key ssl_ciphers ssl_client_certificate ssl_crl ssl_dhparam ssl_engine ssl_prefer_server_ciphers ssl_protocols ssl_session_cache ssl_session_timeout ssl_verify_client ssl_verify_depth starttls stub_status sub_filter sub_filter_once sub_filter_types tcp_nodelay tcp_nopush thread_stack_size timeout timer_resolution types_hash_bucket_size types_hash_maLine truncated
|
||||||
|
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror"),require("../../addon/mode/simple")):"function"==typeof define&&define.amd?define(["../../lib/codemirror","../../addon/mode/simple"],e):e(CodeMirror)}(function(e){"use strict";var n="from",t=new RegExp("^(\\s*)\\b(from)\\b","i"),r=["run","cmd","entrypoint","shell"],o=new RegExp("^(\\s*)("+r.join("|")+")(\\s+\\[)","i"),l="expose",s=new RegExp("^(\\s*)("+l+")(\\s+)","i"),n="("+[n,l].concat(r).concat(["arg","from","maintainer","label","env","add","copy","volume","user","workdir","onbuild","stopsignal","healthcheck","shell"]).join("|")+")",l=new RegExp("^(\\s*)"+n+"(\\s*)(#.*)?$","i"),r=new RegExp("^(\\s*)"+n+"(\\s+)","i");e.defineSimpleMode("dockerfile",{start:[{regex:/^\s*#.*$/,sol:!0,token:"comment"},{regex:t,token:[null,"keyword"],sol:!0,next:"from"},{regex:l,token:[null,"keyword",null,"error"],sol:!0},{regex:o,token:[null,"keyword",null],sol:!0,next:"array"},{regex:s,token:[null,"keyword",null],sol:!0,next:"expose"},{regex:r,token:[null,"keyword",null],sol:!0,next:"arguments"},{regex:/./,token:null}],from:[{regex:/\s*$/,token:null,next:"start"},{regex:/(\s*)(#.*)$/,token:[null,"error"],next:"start"},{regex:/(\s*\S+\s+)(as)/i,token:[null,"keyword"],next:"start"},{token:null,next:"start"}],single:[{regex:/(?:[^\\']|\\.)/,token:"string"},{regex:/'/,token:"string",pop:!0}],double:[{regex:/(?:[^\\"]|\\.)/,token:"string"},{regex:/"/,token:"string",pop:!0}],array:[{regex:/\]/,token:null,next:"start"},{regex:/"(?:[^\\"]|\\.)*"?/,token:"string"}],expose:[{regex:/\d+$/,token:"number",next:"start"},{regex:/[^\d]+$/,token:null,next:"start"},{regex:/\d+/,token:"number"},{regex:/[^\d]+/,token:null},{token:null,next:"start"}],arguments:[{regex:/^\s*#.*$/,sol:!0,token:"comment"},{regex:/"(?:[^\\"]|\\.)*"?$/,token:"string",next:"start"},{regex:/"/,token:"string",push:"double"},{regex:/'(?:[^\\']|\\.)*'?$/,token:"string",next:"start"},{regex:/'/,token:"string",push:"single"},{regex:/[^#"']+[\\`]$/,token:null},{regex:/[^#"']+$/,token:null,next:"start"},{regex:/[^#"']+/,token:null},{token:null,next:"start"}],meta:{lineComment:"#"}}),e.defineMIME("text/x-dockerfile","dockerfile")});;
|
||||||
|
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(e){"use strict";e.defineMode("lua",function(e,t){var n=e.indentUnit;function a(e){return new RegExp("^(?:"+e.join("|")+")$","i")}var r=a(t.specials||[]),o=a(["_G","_VERSION","assert","collectgarbage","dofile","error","getfenv","getmetatable","ipairs","load","loadfile","loadstring","module","next","pairs","pcall","print","rawequal","rawget","rawset","require","select","setfenv","setmetatable","tonumber","tostring","type","unpack","xpcall","coroutine.create","coroutine.resume","coroutine.running","coroutine.status","coroutine.wrap","coroutine.yield","debug.debug","debug.getfenv","debug.gethook","debug.getinfo","debug.getlocal","debug.getmetatable","debug.getregistry","debug.getupvalue","debug.setfenv","debug.sethook","debug.setlocal","debug.setmetatable","debug.setupvalue","debug.traceback","close","flush","lines","read","seek","setvbuf","write","io.close","io.flush","io.input","io.lines","io.open","io.output","io.popen","io.read","io.stderr","io.stdin","io.stdout","io.tmpfile","io.type","io.write","math.abs","math.acos","math.asin","math.atan","math.atan2","math.ceil","math.cos","math.cosh","math.deg","math.exp","math.floor","math.fmod","math.frexp","math.huge","math.ldexp","math.log","math.log10","math.max","math.min","math.modf","math.pi","math.pow","math.rad","math.random","math.randomseed","math.sin","math.sinh","math.sqrt","math.tan","math.tanh","os.clock","os.date","os.difftime","os.execute","os.exit","os.getenv","os.remove","os.rename","os.setlocale","os.time","os.tmpname","package.cpath","package.loaded","package.loaders","package.loadlib","package.path","package.preload","package.seeall","string.byte","string.char","string.dump","string.find","string.format","string.gmatch","string.gsub","string.len","string.lower","string.match","string.rep","string.reverse","string.sub","string.upper","table.concat","table.insert","table.maxn","table.remove","table.sort"]),i=a(["and","break","elseif","false","nil","not","or","return","true","function","end","if","then","else","do","while","repeat","until","for","in","local"]),l=a(["function","if","repeat","do","\\(","{"]),s=a(["end","until","\\)","}"]),u=new RegExp("^(?:"+["end","until","\\)","}","else","elseif"].join("|")+")","i");function c(e){for(var t=0;e.eat("=");)++t;return e.eat("["),t}function m(e,t){var r,n=e.next();return"-"==n&&e.eat("-")?e.eat("[")&&e.eat("[")?(t.cur=d(c(e),"comment"))(e,t):(e.skipToEnd(),"comment"):'"'==n||"'"==n?(t.cur=(r=n,function(e,t){for(var n,a=!1;null!=(n=e.next())&&(n!=r||a);)a=!a&&"\\"==n;return a||(t.cur=m),"string"}))(e,t):"["==n&&/[\[=]/.test(e.peek())?(t.cur=d(c(e),"string"))(e,t):/\d/.test(n)?(e.eatWhile(/[\w.%]/),"number"):/[\w_]/.test(n)?(e.eatWhile(/[\w\\\-_.]/),"variable"):null}function d(r,o){return function(e,t){for(var n,a=null;null!=(n=e.next());)if(null==a)"]"==n&&(a=0);else if("="==n)++a;else{if("]"==n&&a==r){t.cur=m;break}a=null}return o}}return{startState:function(e){return{basecol:e||0,indentDepth:0,cur:m}},token:function(e,t){if(e.eatSpace())return null;var n=t.cur(e,t),e=e.current();return"variable"==n&&(i.test(e)?n="keyword":o.test(e)?n="builtin":r.test(e)&&(n="variable-2")),"comment"!=n&&"string"!=n&&(l.test(e)?++t.indentDepth:s.test(e)&&--t.indentDepth),n},indent:function(e,t){t=u.test(t);return e.basecol+n*(e.indentDepth-(t?1:0))},electricInput:/^\s*(?:end|until|else|\)|\})$/,lineComment:"--",blockCommentStart:"--[[",blockCommentEnd:"]]"}}),e.defineMIME("text/x-lua","lua")});;
|
||||||
|
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror"),require("../../addon/mode/simple")):"function"==typeof define&&define.amd?define(["../../lib/codemirror","../../addon/mode/simple"],e):e(CodeMirror)}(function(e){"use strict";e.defineSimpleMode("rust",{start:[{regex:/b?"/,token:"string",next:"string"},{regex:/b?r"/,token:"string",next:"string_raw"},{regex:/b?r#+"/,token:"string",next:"string_raw_hash"},{regex:/'(?:[^'\\]|\\(?:[nrt0'"]|x[\da-fA-F]{2}|u\{[\da-fA-F]{6}\}))'/,token:"string-2"},{regex:/b'(?:[^']|\\(?:['\\nrt0]|x[\da-fA-F]{2}))'/,token:"string-2"},{regex:/(?:(?:[0-9][0-9_]*)(?:(?:[Ee][+-]?[0-9_]+)|\.[0-9_]+(?:[Ee][+-]?[0-9_]+)?)(?:f32|f64)?)|(?:0(?:b[01_]+|(?:o[0-7_]+)|(?:x[0-9a-fA-F_]+))|(?:[0-9][0-9_]*))(?:u8|u16|u32|u64|i8|i16|i32|i64|isize|usize)?/,token:"number"},{regex:/(let(?:\s+mut)?|fn|enum|mod|struct|type|union)(\s+)([a-zA-Z_][a-zA-Z0-9_]*)/,token:["keyword",null,"def"]},{regex:/(?:abstract|alignof|as|async|await|box|break|continue|const|crate|do|dyn|else|enum|extern|fn|for|final|if|impl|in|loop|macro|match|mod|move|offsetof|override|priv|proc|pub|pure|ref|return|self|sizeof|static|struct|super|trait|type|typeof|union|unsafe|unsized|use|virtual|where|while|yield)\b/,token:"keyword"},{regex:/\b(?:Self|isize|usize|char|bool|u8|u16|u32|u64|f16|f32|f64|i8|i16|i32|i64|str|Option)\b/,token:"atom"},{regex:/\b(?:true|false|Some|None|Ok|Err)\b/,token:"builtin"},{regex:/\b(fn)(\s+)([a-zA-Z_][a-zA-Z0-9_]*)/,token:["keyword",null,"def"]},{regex:/#!?\[.*\]/,token:"meta"},{regex:/\/\/.*/,token:"comment"},{regex:/\/\*/,token:"comment",next:"comment"},{regex:/[-+\/*=<>!]+/,token:"operator"},{regex:/[a-zA-Z_]\w*!/,token:"variable-3"},{regex:/[a-zA-Z_]\w*/,token:"variable"},{regex:/[\{\[\(]/,indent:!0},{regex:/[\}\]\)]/,dedent:!0}],string:[{regex:/"/,token:"string",next:"start"},{regex:/(?:[^\\"]|\\(?:.|$))*/,token:"string"}],string_raw:[{regex:/"/,token:"string",next:"start"},{regex:/[^"]*/,token:"string"}],string_raw_hash:[{regex:/"#+/,token:"string",next:"start"},{regex:/(?:[^"]|"(?!#))*/,token:"string"}],comment:[{regex:/.*?\*\//,token:"comment",next:"start"},{regex:/.*/,token:"comment"}],meta:{dontIndentStates:["comment"],electricInput:/^\s*\}$/,blockCommentStart:"/*",blockCommentEnd:"*/",lineComment:"//",fold:"brace"}}),e.defineMIME("text/x-rustsrc","rust"),e.defineMIME("text/rust","rust")});;
|
||||||
|
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(e){"use strict";e.defineMode("diff",function(){var o={"+":"positive","-":"negative","@":"meta"};return{token:function(e){var r=e.string.search(/[\t ]+?$/);if(!e.sol()||0===r)return e.skipToEnd(),("error "+(o[e.string.charAt(0)]||"")).replace(/ $/,"");var i=o[e.peek()]||e.skipToEnd();return-1===r?e.skipToEnd():e.pos=r,i}}}),e.defineMIME("text/x-diff","diff")});;
|
||||||
|
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(o){"use strict";function e(e){for(var t={},n=0,r=e.length;n<r;++n)t[e[n]]=!0;return t}var t=["alias","and","BEGIN","begin","break","case","class","def","defined?","do","else","elsif","END","end","ensure","false","for","if","in","module","next","not","or","redo","rescue","retry","return","self","super","then","true","undef","unless","until","when","while","yield","nil","raise","throw","catch","fail","loop","callcc","caller","lambda","proc","public","protected","private","require","load","require_relative","extend","autoload","__END__","__FILE__","__LINE__","__dir__"],a=e(t),u=e(["def","class","case","for","while","until","module","catch","loop","proc","begin"]),p=e(["end","until"]),k={"[":"]","{":"}","(":")"},m={"]":"[","}":"{",")":"("};o.defineMode("ruby",function(i){var l;function f(e,t,n){return n.tokenize.push(e),e(t,n)}function d(e,t){if(e.sol()&&e.match("=begin")&&e.eol())return t.tokenize.push(c),"comment";if(e.eatSpace())return null;var n,r,i,o,a,u=e.next();if("`"==u||"'"==u||'"'==u)return f(s(u,"string",'"'==u||"`"==u),e,t);if("/"==u)return function(e){var t,n=e.pos,r=0,i=!1,o=!1;for(;null!=(t=e.next());)if(o)o=!1;else{if(-1<"[{(".indexOf(t))r++;else if(-1<"]})".indexOf(t)){if(--r<0)break}else if("/"==t&&0==r){i=!0;break}o="\\"==t}return e.backUp(e.pos-n),i}(e)?f(s(u,"string-2",!0),e,t):"operator";if("%"==u)return i="string",n=!0,e.eat("s")?i="atom":e.eat(/[WQ]/)?i="string":e.eat(/[r]/)?i="string-2":e.eat(/[wxq]/)&&(n=!(i="string")),(r=e.eat(/[^\w\s=]/))?f(s(r=k.propertyIsEnumerable(r)?k[r]:r,i,n,!0),e,t):"operator";if("#"==u)return e.skipToEnd(),"comment";if("<"==u&&(r=e.match(/^<([-~])[\`\"\']?([a-zA-Z_?]\w*)[\`\"\']?(?:;|$)/)))return f((o=r[2],a=r[1],function(e,t){return a&&e.eatSpace(),e.match(o)?t.tokenize.pop():e.skipToEnd(),"string"}),e,t);if("0"==u)return e.eat("x")?e.eatWhile(/[\da-fA-F]/):e.eat("b")?e.eatWhile(/[01]/):e.eatWhile(/[0-7]/),"number";if(/\d/.test(u))return e.match(/^[\d_]*(?:\.[\d_]+)?(?:[eE][+\-]?[\d_]+)?/),"number";if("?"!=u)return":"==u?e.eat("'")?f(s("'","atom",!1),e,t):e.eat('"')?f(s('"',"atom",!0),e,t):e.eat(/[\<\>]/)?(e.eat(/[\<\>]/),"atom"):e.eat(/[\+\-\*\/\&\|\:\!]/)?"atom":e.eat(/[a-zA-Z$@_\xa1-\uffff]/)?(e.eatWhile(/[\w$\xa1-\uffff]/),e.eat(/[\?\!\=]/),"atom"):"operator":"@"==u&&e.match(/^@?[a-zA-Z_\xa1-\uffff]/)?(e.eat("@"),e.eatWhile(/[\w\xa1-\uffff]/),"variable-2"):"$"==u?(e.eat(/[a-zA-Z_]/)?e.eatWhile(/[\w]/):e.eat(/\d/)?e.eat(/\d/):e.next(),"variable-3"):/[a-zA-Z_\xa1-\uffff]/.test(u)?(e.eatWhile(/[\w\xa1-\uffff]/),e.eat(/[\?\!]/),e.eat(":")?"atom":"ident"):"|"!=u||!t.varList&&"{"!=t.lastTok&&"do"!=t.lastTok?/[\(\)\[\]{}\\;]/.test(u)?(l=u,null):"-"==u&&e.eat(">")?"arrow":/[=+\-\/*:\.^%<>~|]/.test(u)?(i=e.eatWhile(/[=+\-\/*:\.^%<>~|]/),"."!=u||i||(l="."),"operator"):null:(l="|",null);for(;e.match(/^\\[CM]-/););return e.eat("\\")?e.eatWhile(/\w/):e.next(),"string"}function s(i,o,a,u){return function(e,t){var n,r=!1;for("read-quoted-paused"===t.context.type&&(t.context=t.context.prev,e.eat("}"));null!=(n=e.next());){if(n==i&&(u||!r)){t.tokenize.pop();break}if(a&&"#"==n&&!r){if(e.eat("{")){"}"==i&&(t.context={prev:t.context,type:"read-quoted-paused"}),t.tokenize.push(function n(r){return r=r||1,function(e,t){if("}"==e.peek()){if(1==r)return t.tokenize.pop(),t.tokenize[t.tokenize.length-1](e,t);t.tokenize[t.tokenize.length-1]=n(r-1)}else"{"==e.peek()&&(t.tokenize[t.tokenize.length-1]=n(r+1));return d(e,t)}}());break}if(/[@\$]/.test(e.peek())){t.tokenize.push(function(){var n=!1;return function(e,t){return n?(t.tokenize.pop(),t.tokenize[t.tokenize.length-1](e,t)):(n=!0,d(e,t))}}());break}}r=!r&&"\\"==n}return o}}function c(e,t){return e.sol()&&e.match("=end")&&e.eol()&&t.tokenize.pop(),e.skipToEnd(),"comment"}return{startState:function(){return{tokenize:[d],indented:0,context:{type:"top",indented:-i.indentUnit},continuedLine:!1,lastTok:null,varList:!1}},token:function(e,t){l=null,e.sol()&&(t.indented=e.indentation());var n,r,i=t.tokenize[t.tokenize.length-1](e,t),o=l;return"ident"==i&&(r=e.current(),"keyword"==(i="."==t.lastTok?"property":a.propertyIsEnumerable(e.current())?"keyword":/^[A-Z]/.test(r)?"tag":"def"==t.lastTok||"class"==t.lastTok||t.varList?"def":"variable")&&(u.propertyIsEnumerable(o=r)?n="indent":p.propertyIsEnumerable(r)?n="dedent":(("if"==r||"unless"==r)&&e.column()==e.indentation()||"do"==r&&t.context.indented<t.indented)&&(n="indent"))),(l||i&&"comment"!=i)&&(t.lastTok=o),"|"==l&&(t.varList=!t.varList),"indent"==n||/[\(\[\{]/.test(l)?t.context={prev:t.context,type:l||i,indented:t.indented}:("dedent"==n||/[\)\]\}]/.test(l))&&t.context.prev&&(t.context=t.context.prev),e.eol()&&(t.continuedLine="\\"==l||"operator"==i),i},indent:function(e,t){if(e.tokenize[e.tokenize.length-1]!=d)return o.Pass;var n=t&&t.charAt(0),r=e.context,n=r.type==m[n]||"keyword"==r.type&Line truncated
|
||||||
|
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(e){"use strict";function R(e,t){return e.string.charAt(e.pos+(t||0))}function c(e,t){var r;return t?(r=e.pos-t,e.string.substr(0<=r?r:0,t)):e.string.substr(0,e.pos-1)}function p(e,t){var r=e.string.length,n=r-e.pos+1;return e.string.substr(e.pos,t&&t<r?t:n)}function _(e,t){var r,t=e.pos+t;t<=0?e.pos=0:t>=(r=e.string.length-1)?e.pos=r:e.pos=t}e.defineMode("perl",function(){var a={"->":4,"++":4,"--":4,"**":4,"=~":4,"!~":4,"*":4,"/":4,"%":4,x:4,"+":4,"-":4,".":4,"<<":4,">>":4,"<":4,">":4,"<=":4,">=":4,lt:4,gt:4,le:4,ge:4,"==":4,"!=":4,"<=>":4,eq:4,ne:4,cmp:4,"~~":4,"&":4,"|":4,"^":4,"&&":4,"||":4,"//":4,"..":4,"...":4,"?":4,":":4,"=":4,"+=":4,"-=":4,"*=":4,",":4,"=>":4,"::":4,not:4,and:4,or:4,xor:4,BEGIN:[5,1],END:[5,1],PRINT:[5,1],PRINTF:[5,1],GETC:[5,1],READ:[5,1],READLINE:[5,1],DESTROY:[5,1],TIE:[5,1],TIEHANDLE:[5,1],UNTIE:[5,1],STDIN:5,STDIN_TOP:5,STDOUT:5,STDOUT_TOP:5,STDERR:5,STDERR_TOP:5,$ARG:5,$_:5,"@ARG":5,"@_":5,$LIST_SEPARATOR:5,'$"':5,$PROCESS_ID:5,$PID:5,$$:5,$REAL_GROUP_ID:5,$GID:5,"$(":5,$EFFECTIVE_GROUP_ID:5,$EGID:5,"$)":5,$PROGRAM_NAME:5,$0:5,$SUBSCRIPT_SEPARATOR:5,$SUBSEP:5,"$;":5,$REAL_USER_ID:5,$UID:5,"$<":5,$EFFECTIVE_USER_ID:5,$EUID:5,"$>":5,$a:5,$b:5,$COMPILING:5,"$^C":5,$DEBUGGING:5,"$^D":5,"${^ENCODING}":5,$ENV:5,"%ENV":5,$SYSTEM_FD_MAX:5,"$^F":5,"@F":5,"${^GLOBAL_PHASE}":5,"$^H":5,"%^H":5,"@INC":5,"%INC":5,$INPLACE_EDIT:5,"$^I":5,"$^M":5,$OSNAME:5,"$^O":5,"${^OPEN}":5,$PERLDB:5,"$^P":5,$SIG:5,"%SIG":5,$BASETIME:5,"$^T":5,"${^TAINT}":5,"${^UNICODE}":5,"${^UTF8CACHE}":5,"${^UTF8LOCALE}":5,$PERL_VERSION:5,"$^V":5,"${^WIN32_SLOPPY_STAT}":5,$EXECUTABLE_NAME:5,"$^X":5,$1:5,$MATCH:5,"$&":5,"${^MATCH}":5,$PREMATCH:5,"$`":5,"${^PREMATCH}":5,$POSTMATCH:5,"$'":5,"${^POSTMATCH}":5,$LAST_PAREN_MATCH:5,"$+":5,$LAST_SUBMATCH_RESULT:5,"$^N":5,"@LAST_MATCH_END":5,"@+":5,"%LAST_PAREN_MATCH":5,"%+":5,"@LAST_MATCH_START":5,"@-":5,"%LAST_MATCH_START":5,"%-":5,$LAST_REGEXP_CODE_RESULT:5,"$^R":5,"${^RE_DEBUG_FLAGS}":5,"${^RE_TRIE_MAXBUF}":5,$ARGV:5,"@ARGV":5,ARGV:5,ARGVOUT:5,$OUTPUT_FIELD_SEPARATOR:5,$OFS:5,"$,":5,$INPUT_LINE_NUMBER:5,$NR:5,"$.":5,$INPUT_RECORD_SEPARATOR:5,$RS:5,"$/":5,$OUTPUT_RECORD_SEPARATOR:5,$ORS:5,"$\\":5,$OUTPUT_AUTOFLUSH:5,"$|":5,$ACCUMULATOR:5,"$^A":5,$FORMAT_FORMFEED:5,"$^L":5,$FORMAT_PAGE_NUMBER:5,"$%":5,$FORMAT_LINES_LEFT:5,"$-":5,$FORMAT_LINE_BREAK_CHARACTERS:5,"$:":5,$FORMAT_LINES_PER_PAGE:5,"$=":5,$FORMAT_TOP_NAME:5,"$^":5,$FORMAT_NAME:5,"$~":5,"${^CHILD_ERROR_NATIVE}":5,$EXTENDED_OS_ERROR:5,"$^E":5,$EXCEPTIONS_BEING_CAUGHT:5,"$^S":5,$WARNING:5,"$^W":5,"${^WARNING_BITS}":5,$OS_ERROR:5,$ERRNO:5,"$!":5,"%OS_ERROR":5,"%ERRNO":5,"%!":5,$CHILD_ERROR:5,"$?":5,$EVAL_ERROR:5,"$@":5,$OFMT:5,"$#":5,"$*":5,$ARRAY_BASE:5,"$[":5,$OLD_PERL_VERSION:5,"$]":5,if:[1,1],elsif:[1,1],else:[1,1],while:[1,1],unless:[1,1],for:[1,1],foreach:[1,1],abs:1,accept:1,alarm:1,atan2:1,bind:1,binmode:1,bless:1,bootstrap:1,break:1,caller:1,chdir:1,chmod:1,chomp:1,chop:1,chown:1,chr:1,chroot:1,close:1,closedir:1,connect:1,continue:[1,1],cos:1,crypt:1,dbmclose:1,dbmopen:1,default:1,defined:1,delete:1,die:1,do:1,dump:1,each:1,endgrent:1,endhostent:1,endnetent:1,endprotoent:1,endpwent:1,endservent:1,eof:1,eval:1,exec:1,exists:1,exit:1,exp:1,fcntl:1,fileno:1,flock:1,fork:1,format:1,formline:1,getc:1,getgrent:1,getgrgid:1,getgrnam:1,gethostbyaddr:1,gethostbyname:1,gethostent:1,getlogin:1,getnetbyaddr:1,getnetbyname:1,getnetent:1,getpeername:1,getpgrp:1,getppid:1,getpriority:1,getprotobyname:1,getprotobynumber:1,getprotoent:1,getpwent:1,getpwnam:1,getpwuid:1,getservbyname:1,getservbyport:1,getservent:1,getsockname:1,getsockopt:1,given:1,glob:1,gmtime:1,goto:1,grep:1,hex:1,import:1,index:1,int:1,ioctl:1,join:1,keys:1,kill:1,last:1,lc:1,lcfirst:1,length:1,link:1,listen:1,local:2,localtime:1,lock:1,log:1,lstat:1,m:null,map:1,mkdir:1,msgctl:1,msgget:1,msgrcv:1,msgsnd:1,my:2,new:1,next:1,no:1,oct:1,open:1,opendir:1,ord:1,our:2,pack:1,package:1,pipe:1,pop:1,pos:1,print:1,printf:1,prototype:1,push:1,q:null,qq:null,qr:null,quotemeta:null,qw:null,qx:null,rand:1,read:1,readdir:1,readline:1,readlink:1,readpipe:1,recv:1,redo:1,ref:1,rename:1,require:1,reset:1,return:1,reverse:1,rewinddir:1,rindex:1,rmdir:1,s:null,say:1,scalar:1,seek:1,seekdir:1,select:1,semctl:1,semget:1,semop:1,send:1,setgrent:1,sethostent:1,setnetent:1,setpgrp:1,setpriority:1,setprotoent:1,setpwent:1,setservent:1,setsockopt:1,shift:1,shmctl:1,shmget:1,shmread:1,shmwrite:1,shutdown:1,sin:1,sleep:1,socket:1,socketpair:1,sort:1,splice:1,split:1,sprintf:1,sqrt:1,srand:1,stat:1,state:1,study:1,sub:1,substr:1,symlink:1,syscall:1,sysopen:1,sysread:1,sysseek:1,system:1,syswrite:1,tell:1,telldir:1,tie:1,tied:1,time:1,times:1,tr:null,truncate:1,uc:1,ucfirst:1,umask:1,undef:1,unlink:1,unpack:1,unshift:1,untie:1,use:1,utime:1,values:1,vec:1,wait:1,waitpid:1,wantarray:1,warn:1,whenLine truncated
|
||||||
|
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(e){"use strict";e.defineMode("cmake",function(){var i=/({)?[a-zA-Z0-9_]+(})?/;function r(e,n){for(var t,i,r=!1;!e.eol()&&(t=e.next())!=n.pending;){if("$"===t&&"\\"!=i&&'"'==n.pending){r=!0;break}i=t}return r&&e.backUp(1),t==n.pending?n.continueString=!1:n.continueString=!0,"string"}return{startState:function(){var e={inDefinition:!1,inInclude:!1,continueString:!1,pending:!1};return e},token:function(e,n){return e.eatSpace()?null:(n=n,"$"===(t=(e=e).next())?e.match(i)?"variable-2":"variable":n.continueString?(e.backUp(1),r(e,n)):e.match(/(\s+)?\w+\(/)||e.match(/(\s+)?\w+\ \(/)?(e.backUp(1),"def"):"#"==t?(e.skipToEnd(),"comment"):"'"==t||'"'==t?(n.pending=t,r(e,n)):"("==t||")"==t?"bracket":t.match(/[0-9]/)?"number":(e.eatWhile(/[\w-]/),null));var t}}}),e.defineMIME("text/x-cmake","cmake")});;
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
// SQLite editor of the file manager: the agent opens the database and runs
|
||||||
|
// the queries, so a database in use keeps its locks. Tables and views in
|
||||||
|
// tabs, rows a page at a time; a click on a value edits it (Enter saves,
|
||||||
|
// Esc cancels, Shift+Enter a new line, the ∅ button makes it NULL). Rows are
|
||||||
|
// added and deleted, and a tab runs any SQL.
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
var PAGE = 200;
|
||||||
|
var esc = function (s) { return String(s).replace(/[&<>"]/g, function (c) { return {'&': '&', '<': '<', '>': '>', '"': '"'}[c]; }); };
|
||||||
|
|
||||||
|
function cellHTML(c) {
|
||||||
|
if (c.t === 'n') return '<i class="dbx-null">NULL</i>';
|
||||||
|
if (c.t === 'b') return '<i class="dbx-null">BLOB ' + esc(c.v) + ' Б</i>';
|
||||||
|
var v = c.v || '';
|
||||||
|
return esc(v.length > 300 ? v.slice(0, 300) + '…' : v);
|
||||||
|
}
|
||||||
|
// The type a typed value goes as: what the value was, or what the column
|
||||||
|
// is declared as, when the text fits it.
|
||||||
|
function typeFor(text, was, decl) {
|
||||||
|
var d = (decl || '').toUpperCase();
|
||||||
|
var int = /^\s*-?\d+\s*$/.test(text), num = text.trim() !== '' && isFinite(Number(text));
|
||||||
|
if (was === 'i' || (was !== 's' && /INT/.test(d))) return int ? 'i' : num ? 'r' : 's';
|
||||||
|
if (was === 'r' || (was !== 's' && /REAL|FLOA|DOUB|NUM|DEC/.test(d))) return num ? 'r' : 's';
|
||||||
|
return 's';
|
||||||
|
}
|
||||||
|
|
||||||
|
function open(box, path, call, toast) {
|
||||||
|
var S = {tables: [], cur: null, offset: 0, data: null, sel: new Set()};
|
||||||
|
box.innerHTML =
|
||||||
|
'<div class="dbx">' +
|
||||||
|
'<div class="dbx-tabs" role="tablist"></div>' +
|
||||||
|
'<div class="dbx-tools">' +
|
||||||
|
' <span class="muted dbx-info"></span>' +
|
||||||
|
' <span class="dbx-pager"><button type="button" class="btn ghost small icon dbx-prev" title="Назад" aria-label="Назад">‹</button>' +
|
||||||
|
' <span class="muted dbx-range"></span><button type="button" class="btn ghost small icon dbx-next" title="Дальше" aria-label="Дальше">›</button></span>' +
|
||||||
|
' <span class="dbx-sp"></span>' +
|
||||||
|
' <button type="button" class="btn ghost small dbx-add">+ Строка</button>' +
|
||||||
|
' <button type="button" class="btn danger small dbx-del" disabled>Удалить</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="dbx-grid"></div>' +
|
||||||
|
'<div class="dbx-sql" hidden>' +
|
||||||
|
' <textarea class="dbx-q" spellcheck="false" placeholder="SELECT * FROM … - Ctrl+Enter выполнить"></textarea>' +
|
||||||
|
' <div class="dbx-sqlbar"><button type="button" class="dbx-run">Выполнить</button><span class="muted dbx-sqlmsg"></span></div>' +
|
||||||
|
' <div class="dbx-sqlres"></div>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>';
|
||||||
|
var $ = function (s) { return box.querySelector(s); };
|
||||||
|
|
||||||
|
function busy(on) { box.querySelector('.dbx').classList.toggle('busy', on); }
|
||||||
|
function req(op, args) {
|
||||||
|
args.path = path;
|
||||||
|
busy(true);
|
||||||
|
return call(op, args).then(function (r) { busy(false); return r; }, function (e) { busy(false); throw e; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function tabs() {
|
||||||
|
var t = $('.dbx-tabs');
|
||||||
|
t.innerHTML = '';
|
||||||
|
S.tables.forEach(function (x) {
|
||||||
|
var b = document.createElement('button');
|
||||||
|
b.type = 'button';
|
||||||
|
b.textContent = x.name;
|
||||||
|
if (x.type === 'view') b.title = 'представление - только чтение';
|
||||||
|
b.className = x.name === S.cur ? 'on' : '';
|
||||||
|
b.onclick = function () { show(x.name, 0); };
|
||||||
|
t.appendChild(b);
|
||||||
|
});
|
||||||
|
var q = document.createElement('button');
|
||||||
|
q.type = 'button';
|
||||||
|
q.textContent = 'SQL';
|
||||||
|
q.className = 'dbx-sqltab' + (S.cur === null ? ' on' : '');
|
||||||
|
q.onclick = sqlTab;
|
||||||
|
t.appendChild(q);
|
||||||
|
}
|
||||||
|
|
||||||
|
function show(name, offset) {
|
||||||
|
S.cur = name; S.offset = offset; S.sel.clear();
|
||||||
|
tabs();
|
||||||
|
$('.dbx-sql').hidden = true;
|
||||||
|
$('.dbx-grid').hidden = false;
|
||||||
|
$('.dbx-tools').hidden = false;
|
||||||
|
return req('db.rows', {table: name, offset: offset, limit: PAGE}).then(function (r) {
|
||||||
|
if (!r.ok) { $('.dbx-grid').innerHTML = '<p class="dv-note">' + esc(r.error || 'ошибка') + '</p>'; return; }
|
||||||
|
S.data = r.data;
|
||||||
|
draw();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function draw() {
|
||||||
|
var d = S.data, t = d.table, editable = !!t.key;
|
||||||
|
// An INTEGER PRIMARY KEY column already shows the rowid.
|
||||||
|
var showRowid = t.key === 'rowid' && !t.alias;
|
||||||
|
$('.dbx-info').textContent = d.total + ' ' + plural(d.total) + (t.type === 'view' ? ' · представление' : !editable ? ' · только чтение' : '');
|
||||||
|
$('.dbx-range').textContent = d.rows.length ? (d.offset + 1) + '–' + (d.offset + d.rows.length) : '';
|
||||||
|
$('.dbx-prev').disabled = d.offset <= 0;
|
||||||
|
$('.dbx-next').disabled = d.offset + d.rows.length >= d.total;
|
||||||
|
$('.dbx-pager').hidden = d.total <= PAGE;
|
||||||
|
$('.dbx-add').hidden = t.type !== 'table';
|
||||||
|
$('.dbx-del').hidden = !editable;
|
||||||
|
selInfo();
|
||||||
|
var h = '<table class="dbx-t"><thead><tr>' + (editable ? '<th class="dbx-ck"><input type="checkbox" class="dbx-all" aria-label="Выбрать все"></th>' : '') +
|
||||||
|
(showRowid ? '<th class="dbx-k">rowid</th>' : '');
|
||||||
|
t.columns.forEach(function (c) {
|
||||||
|
h += '<th title="' + esc(c.type + (c.pk ? ' · ключ' : '') + (c.notnull ? ' · NOT NULL' : '')) + '">' + esc(c.name) +
|
||||||
|
(c.type ? '<small>' + esc(c.type.toLowerCase()) + '</small>' : '') + '</th>';
|
||||||
|
});
|
||||||
|
h += '</tr></thead><tbody>';
|
||||||
|
d.rows.forEach(function (row, i) {
|
||||||
|
h += '<tr data-i="' + i + '">' + (editable ? '<td class="dbx-ck"><input type="checkbox" aria-label="Выбрать строку"></td>' : '') +
|
||||||
|
(showRowid ? '<td class="dbx-k">' + esc(row.k) + '</td>' : '');
|
||||||
|
row.v.forEach(function (c, j) {
|
||||||
|
h += '<td data-j="' + j + '" class="' + (c.t === 'i' || c.t === 'r' ? 'num' : '') + (editable && c.t !== 'b' ? ' ed' : '') + '">' + cellHTML(c) + '</td>';
|
||||||
|
});
|
||||||
|
h += '</tr>';
|
||||||
|
});
|
||||||
|
h += '</tbody></table>';
|
||||||
|
if (!d.rows.length) h += '<p class="dv-note">строк нет</p>';
|
||||||
|
$('.dbx-grid').innerHTML = h;
|
||||||
|
$('.dbx-grid').scrollTop = 0;
|
||||||
|
fixWidths($('.dbx-grid table'));
|
||||||
|
}
|
||||||
|
// The columns keep the widths they first came out at, so a value being
|
||||||
|
// edited does not move the table about.
|
||||||
|
function fixWidths(table) {
|
||||||
|
if (!table) return;
|
||||||
|
var ths = table.querySelectorAll('thead th'), ws = [], sum = 0;
|
||||||
|
ths.forEach(function (th) { var w = Math.ceil(th.getBoundingClientRect().width); ws.push(w); sum += w; });
|
||||||
|
ths.forEach(function (th, i) { th.style.width = ws[i] + 'px'; });
|
||||||
|
table.style.width = sum + 'px';
|
||||||
|
table.style.tableLayout = 'fixed';
|
||||||
|
}
|
||||||
|
function plural(n) {
|
||||||
|
var m10 = n % 10, m100 = n % 100;
|
||||||
|
return m10 === 1 && m100 !== 11 ? 'строка' : m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14) ? 'строки' : 'строк';
|
||||||
|
}
|
||||||
|
function selInfo() {
|
||||||
|
var b = $('.dbx-del');
|
||||||
|
b.disabled = !S.sel.size;
|
||||||
|
b.textContent = S.sel.size ? 'Удалить (' + S.sel.size + ')' : 'Удалить';
|
||||||
|
}
|
||||||
|
|
||||||
|
$('.dbx-prev').onclick = function () { show(S.cur, Math.max(0, S.offset - PAGE)); };
|
||||||
|
$('.dbx-next').onclick = function () { show(S.cur, S.offset + PAGE); };
|
||||||
|
|
||||||
|
// ---- selecting rows ----
|
||||||
|
$('.dbx-grid').addEventListener('change', function (ev) {
|
||||||
|
var el = ev.target;
|
||||||
|
if (el.classList.contains('dbx-all')) {
|
||||||
|
box.querySelectorAll('tbody .dbx-ck input').forEach(function (c, i) { c.checked = el.checked; el.checked ? S.sel.add(i) : S.sel.delete(i); });
|
||||||
|
} else if (el.closest('tbody .dbx-ck')) {
|
||||||
|
var i = +el.closest('tr').dataset.i;
|
||||||
|
el.checked ? S.sel.add(i) : S.sel.delete(i);
|
||||||
|
}
|
||||||
|
selInfo();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- editing a value ----
|
||||||
|
var editing = null;
|
||||||
|
$('.dbx-grid').addEventListener('click', function (ev) {
|
||||||
|
var td = ev.target.closest('td.ed');
|
||||||
|
if (!td || td.querySelector('textarea') || ev.target.closest('.dbx-edit')) return;
|
||||||
|
edit(td);
|
||||||
|
});
|
||||||
|
function edit(td) {
|
||||||
|
if (editing) finish(editing, true);
|
||||||
|
var i = +td.parentNode.dataset.i, j = +td.dataset.j;
|
||||||
|
var row = S.data.rows[i], cell = row.v[j], col = S.data.table.columns[j];
|
||||||
|
var hgt = td.offsetHeight;
|
||||||
|
td.classList.add('editing');
|
||||||
|
td.innerHTML = '<div class="dbx-edit"><textarea spellcheck="false"></textarea><button type="button" class="dbx-nullb" title="Сделать NULL" aria-label="Сделать NULL">∅</button></div>';
|
||||||
|
var ta = td.querySelector('textarea');
|
||||||
|
ta.value = cell.t === 'n' ? '' : cell.v;
|
||||||
|
ta.placeholder = cell.t === 'n' ? 'NULL' : '';
|
||||||
|
ta.style.height = Math.max(hgt, 30) + 'px';
|
||||||
|
ta.focus();
|
||||||
|
ta.select();
|
||||||
|
editing = {td: td, i: i, j: j, ta: ta, cell: cell, col: col, nul: false};
|
||||||
|
ta.addEventListener('keydown', function (ev) {
|
||||||
|
if (ev.key === 'Enter' && !ev.shiftKey) { ev.preventDefault(); finish(editing, true); }
|
||||||
|
else if (ev.key === 'Escape') { ev.preventDefault(); ev.stopPropagation(); finish(editing, false); }
|
||||||
|
else if (ev.key === 'Tab') {
|
||||||
|
ev.preventDefault();
|
||||||
|
var cur = editing, next = ev.shiftKey ? cur.td.previousElementSibling : cur.td.nextElementSibling;
|
||||||
|
finish(cur, true);
|
||||||
|
if (next && next.classList.contains('ed')) edit(next);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ta.addEventListener('blur', function () { setTimeout(function () { if (editing && editing.ta === ta && document.activeElement !== td.querySelector('.dbx-nullb')) finish(editing, true); }, 120); });
|
||||||
|
td.querySelector('.dbx-nullb').onmousedown = function (ev) { ev.preventDefault(); editing.nul = true; finish(editing, true); };
|
||||||
|
}
|
||||||
|
function finish(e, keep) {
|
||||||
|
if (!e || editing !== e) return;
|
||||||
|
editing = null;
|
||||||
|
var text = e.ta.value, cell = e.cell;
|
||||||
|
var value = e.nul ? {t: 'n'} : {t: typeFor(text, cell.t, e.col.type), v: text};
|
||||||
|
var same = e.nul ? cell.t === 'n' : (cell.t !== 'n' || text !== '') && text === (cell.v || '') && (cell.t === value.t || cell.t === 's' || value.t === 's');
|
||||||
|
if (cell.t === 'n' && !e.nul && text === '') same = true;
|
||||||
|
e.td.classList.remove('editing');
|
||||||
|
if (!keep || same) { e.td.innerHTML = cellHTML(cell); return; }
|
||||||
|
e.td.innerHTML = cellHTML(value);
|
||||||
|
e.td.classList.add('saving');
|
||||||
|
req('db.set', {table: S.cur, key: S.data.rows[e.i].k, column: e.col.name, value: value}).then(function (r) {
|
||||||
|
e.td.classList.remove('saving');
|
||||||
|
if (!r.ok) {
|
||||||
|
e.td.innerHTML = cellHTML(cell);
|
||||||
|
flash(e.td, 'bad');
|
||||||
|
toast(r.error || 'не сохранилось', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var got = r.data.value;
|
||||||
|
S.data.rows[e.i].v[e.j] = got;
|
||||||
|
// A changed key column moves the row's key too.
|
||||||
|
if (r.data.key !== undefined) S.data.rows[e.i].k = r.data.key;
|
||||||
|
e.td.innerHTML = cellHTML(got);
|
||||||
|
e.td.className = (got.t === 'i' || got.t === 'r' ? 'num' : '') + ' ed';
|
||||||
|
flash(e.td, 'ok');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function flash(td, kind) {
|
||||||
|
td.classList.remove('flash-ok', 'flash-bad');
|
||||||
|
void td.offsetWidth;
|
||||||
|
td.classList.add('flash-' + kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- adding and deleting rows ----
|
||||||
|
$('.dbx-add').onclick = function () {
|
||||||
|
if (box.querySelector('tr.dbx-new')) return;
|
||||||
|
var t = S.data.table, tbody = box.querySelector('.dbx-t tbody');
|
||||||
|
var tr = document.createElement('tr');
|
||||||
|
tr.className = 'dbx-new';
|
||||||
|
var h = (t.key ? '<td class="dbx-ck"></td>' : '') + (t.key === 'rowid' && !t.alias ? '<td class="dbx-k">новая</td>' : '');
|
||||||
|
t.columns.forEach(function (c) {
|
||||||
|
h += '<td><input type="text" data-c="' + esc(c.name) + '" placeholder="' + esc(c.default ? 'по умолч.: ' + c.default : c.notnull ? 'обязательно' : 'NULL') + '"></td>';
|
||||||
|
});
|
||||||
|
tr.innerHTML = h;
|
||||||
|
tbody.insertBefore(tr, tbody.firstChild);
|
||||||
|
var bar = document.createElement('tr');
|
||||||
|
bar.className = 'dbx-newbar';
|
||||||
|
bar.innerHTML = '<td colspan="' + (t.columns.length + 2) + '"><button type="button" class="small dbx-addok">Добавить</button> <button type="button" class="btn ghost small dbx-addno">Отмена</button> <span class="muted">пустое поле - значение по умолчанию</span></td>';
|
||||||
|
tbody.insertBefore(bar, tr.nextSibling);
|
||||||
|
$('.dbx-grid').scrollTop = 0;
|
||||||
|
tr.querySelector('input').focus();
|
||||||
|
var done = function () { tr.remove(); bar.remove(); };
|
||||||
|
bar.querySelector('.dbx-addno').onclick = done;
|
||||||
|
var add = function () {
|
||||||
|
var values = {};
|
||||||
|
tr.querySelectorAll('input').forEach(function (inp) {
|
||||||
|
if (inp.value === '') return;
|
||||||
|
var c = t.columns.filter(function (x) { return x.name === inp.dataset.c; })[0];
|
||||||
|
values[c.name] = {t: typeFor(inp.value, '', c.type), v: inp.value};
|
||||||
|
});
|
||||||
|
req('db.add', {table: S.cur, values: values}).then(function (r) {
|
||||||
|
if (!r.ok) { toast(r.error || 'не добавилось', true); return; }
|
||||||
|
toast('строка добавлена');
|
||||||
|
var total = S.data.total + 1;
|
||||||
|
show(S.cur, Math.floor((total - 1) / PAGE) * PAGE);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
bar.querySelector('.dbx-addok').onclick = add;
|
||||||
|
tr.addEventListener('keydown', function (ev) {
|
||||||
|
if (ev.key === 'Enter') { ev.preventDefault(); add(); }
|
||||||
|
if (ev.key === 'Escape') { ev.preventDefault(); ev.stopPropagation(); done(); }
|
||||||
|
});
|
||||||
|
};
|
||||||
|
$('.dbx-del').onclick = function () {
|
||||||
|
var keys = [];
|
||||||
|
S.sel.forEach(function (i) { keys.push(S.data.rows[i].k); });
|
||||||
|
if (!keys.length || !confirm('Удалить строк: ' + keys.length + '?')) return;
|
||||||
|
req('db.del', {table: S.cur, keys: keys}).then(function (r) {
|
||||||
|
if (!r.ok) { toast(r.error || 'не удалилось', true); return; }
|
||||||
|
toast('удалено строк: ' + r.data.deleted);
|
||||||
|
show(S.cur, S.offset);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- SQL ----
|
||||||
|
function sqlTab() {
|
||||||
|
S.cur = null;
|
||||||
|
tabs();
|
||||||
|
$('.dbx-grid').hidden = true;
|
||||||
|
$('.dbx-tools').hidden = true;
|
||||||
|
$('.dbx-sql').hidden = false;
|
||||||
|
$('.dbx-q').focus();
|
||||||
|
}
|
||||||
|
function run() {
|
||||||
|
var q = $('.dbx-q').value;
|
||||||
|
if (!q.trim()) return;
|
||||||
|
$('.dbx-sqlmsg').textContent = '';
|
||||||
|
req('db.sql', {sql: q}).then(function (r) {
|
||||||
|
var out = $('.dbx-sqlres');
|
||||||
|
if (!r.ok) { out.innerHTML = ''; $('.dbx-sqlmsg').textContent = r.error || 'ошибка'; $('.dbx-sqlmsg').className = 'dbx-sqlmsg bad'; return; }
|
||||||
|
$('.dbx-sqlmsg').className = 'muted dbx-sqlmsg';
|
||||||
|
var d = r.data;
|
||||||
|
if (d.columns) {
|
||||||
|
$('.dbx-sqlmsg').textContent = 'строк: ' + d.rows.length + (d.more ? ' (показаны первые 500)' : '');
|
||||||
|
var h = '<table class="dbx-t"><thead><tr>' + d.columns.map(function (c) { return '<th>' + esc(c) + '</th>'; }).join('') + '</tr></thead><tbody>';
|
||||||
|
d.rows.forEach(function (row) {
|
||||||
|
h += '<tr>' + row.map(function (c) { return '<td class="' + (c.t === 'i' || c.t === 'r' ? 'num' : '') + '">' + cellHTML(c) + '</td>'; }).join('') + '</tr>';
|
||||||
|
});
|
||||||
|
out.innerHTML = h + '</tbody></table>';
|
||||||
|
} else {
|
||||||
|
out.innerHTML = '';
|
||||||
|
$('.dbx-sqlmsg').textContent = 'готово · изменено строк: ' + d.changed;
|
||||||
|
loadTables(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
$('.dbx-run').onclick = run;
|
||||||
|
$('.dbx-q').addEventListener('keydown', function (ev) {
|
||||||
|
if (ev.key === 'Enter' && (ev.ctrlKey || ev.metaKey)) { ev.preventDefault(); run(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
function loadTables(keep) {
|
||||||
|
return req('db.tables', {}).then(function (r) {
|
||||||
|
if (!r.ok) return r;
|
||||||
|
S.tables = r.data.tables;
|
||||||
|
if (keep) { tabs(); return r; }
|
||||||
|
if (S.tables.length) show(S.tables[0].name, 0); else sqlTab();
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return loadTables(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.DBEdit = {open: open};
|
||||||
|
})();
|
||||||
@@ -0,0 +1,528 @@
|
|||||||
|
// Light previews of office files for the file manager, with no libraries:
|
||||||
|
// docx, xlsx and ods are zip archives of XML, opened with the browser's own
|
||||||
|
// DecompressionStream; csv and tsv are parsed here; an old binary .doc only
|
||||||
|
// gives up its text. Each turns into plain HTML the page styles itself.
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const esc = s => String(s).replace(/[&<>"]/g, c => ({'&': '&', '<': '<', '>': '>', '"': '"'}[c]));
|
||||||
|
|
||||||
|
// ---------- zip ----------
|
||||||
|
// Reads the central directory; a file is inflated only when asked for.
|
||||||
|
function openZip(buf) {
|
||||||
|
const b = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
|
||||||
|
const dv = new DataView(b.buffer, b.byteOffset, b.byteLength);
|
||||||
|
let eocd = -1;
|
||||||
|
for (let i = b.length - 22; i >= Math.max(0, b.length - 65557); i--) {
|
||||||
|
if (dv.getUint32(i, true) === 0x06054b50) { eocd = i; break; }
|
||||||
|
}
|
||||||
|
if (eocd < 0) throw new Error('не zip-архив');
|
||||||
|
const count = dv.getUint16(eocd + 10, true);
|
||||||
|
let p = dv.getUint32(eocd + 16, true);
|
||||||
|
const files = new Map();
|
||||||
|
const dec = new TextDecoder();
|
||||||
|
for (let n = 0; n < count && dv.getUint32(p, true) === 0x02014b50; n++) {
|
||||||
|
const method = dv.getUint16(p + 10, true), size = dv.getUint32(p + 20, true);
|
||||||
|
const nameLen = dv.getUint16(p + 28, true), extra = dv.getUint16(p + 30, true), comment = dv.getUint16(p + 32, true);
|
||||||
|
const local = dv.getUint32(p + 42, true);
|
||||||
|
files.set(dec.decode(b.subarray(p + 46, p + 46 + nameLen)), {method, size, local});
|
||||||
|
p += 46 + nameLen + extra + comment;
|
||||||
|
}
|
||||||
|
async function bytes(name) {
|
||||||
|
const f = files.get(name);
|
||||||
|
if (!f) return null;
|
||||||
|
const start = f.local + 30 + dv.getUint16(f.local + 26, true) + dv.getUint16(f.local + 28, true);
|
||||||
|
const raw = b.subarray(start, start + f.size);
|
||||||
|
if (f.method === 0) return raw;
|
||||||
|
if (f.method !== 8) throw new Error('сжатие zip, которое браузер не знает');
|
||||||
|
const out = new Blob([raw]).stream().pipeThrough(new DecompressionStream('deflate-raw'));
|
||||||
|
return new Uint8Array(await new Response(out).arrayBuffer());
|
||||||
|
}
|
||||||
|
async function xml(name) {
|
||||||
|
const raw = await bytes(name);
|
||||||
|
return raw ? new DOMParser().parseFromString(new TextDecoder().decode(raw), 'application/xml') : null;
|
||||||
|
}
|
||||||
|
return {files, bytes, xml};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Children of an element by local name, whatever the namespace prefix.
|
||||||
|
const kids = (el, name) => el ? Array.from(el.children).filter(c => c.localName === name) : [];
|
||||||
|
const kid = (el, name) => kids(el, name)[0] || null;
|
||||||
|
const attr = (el, name) => {
|
||||||
|
if (!el) return null;
|
||||||
|
for (const a of el.attributes) if (a.localName === name) return a.value;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- docx ----------
|
||||||
|
async function docx(buf) {
|
||||||
|
const z = openZip(buf);
|
||||||
|
const doc = await z.xml('word/document.xml');
|
||||||
|
if (!doc) throw new Error('в архиве нет word/document.xml');
|
||||||
|
// Style ids to names, to know headings whatever the language of Word.
|
||||||
|
const styles = {};
|
||||||
|
const st = await z.xml('word/styles.xml');
|
||||||
|
if (st) for (const s of st.getElementsByTagNameNS('*', 'style')) {
|
||||||
|
styles[attr(s, 'styleId')] = (attr(kid(s, 'name'), 'val') || '').toLowerCase();
|
||||||
|
}
|
||||||
|
const body = doc.getElementsByTagNameNS('*', 'body')[0];
|
||||||
|
return '<div class="dv-doc">' + blocks(body, styles) + '</div>';
|
||||||
|
}
|
||||||
|
function blocks(el, styles) {
|
||||||
|
let html = '', list = false;
|
||||||
|
for (const c of el ? el.children : []) {
|
||||||
|
if (c.localName === 'p') {
|
||||||
|
const pPr = kid(c, 'pPr');
|
||||||
|
const isList = !!kid(pPr, 'numPr');
|
||||||
|
if (isList && !list) { html += '<ul>'; list = true; }
|
||||||
|
if (!isList && list) { html += '</ul>'; list = false; }
|
||||||
|
html += para(c, pPr, styles, isList);
|
||||||
|
} else {
|
||||||
|
if (list) { html += '</ul>'; list = false; }
|
||||||
|
if (c.localName === 'tbl') html += table(c, styles);
|
||||||
|
else if (c.localName === 'sdt') html += blocks(kid(c, 'sdtContent'), styles);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return html + (list ? '</ul>' : '');
|
||||||
|
}
|
||||||
|
function para(p, pPr, styles, isList) {
|
||||||
|
const name = styles[attr(kid(pPr, 'pStyle'), 'val')] || '';
|
||||||
|
const h = /^heading (\d)/.exec(name);
|
||||||
|
const tag = isList ? 'li' : name === 'title' ? 'h1' : h ? 'h' + Math.min(6, +h[1] + 1) : 'p';
|
||||||
|
const jc = attr(kid(pPr, 'jc'), 'val');
|
||||||
|
const align = jc === 'center' ? 'center' : (jc === 'right' || jc === 'end') ? 'right' : (jc === 'both' ? 'justify' : '');
|
||||||
|
const inner = runs(p);
|
||||||
|
return '<' + tag + (align ? ' style="text-align:' + align + '"' : '') + '>' + (inner || ' ') + '</' + tag + '>';
|
||||||
|
}
|
||||||
|
function runs(el) {
|
||||||
|
let html = '';
|
||||||
|
for (const c of el.children) {
|
||||||
|
if (c.localName === 'r') html += run(c);
|
||||||
|
else if (c.localName === 'hyperlink' || c.localName === 'smartTag' || c.localName === 'ins' || c.localName === 'sdt' || c.localName === 'sdtContent' || c.localName === 'fldSimple') {
|
||||||
|
const inner = runs(c);
|
||||||
|
html += c.localName === 'hyperlink' ? '<u class="dv-link">' + inner + '</u>' : inner;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
function run(r) {
|
||||||
|
const pr = kid(r, 'rPr');
|
||||||
|
const on = n => { const e = kid(pr, n); return !!e && attr(e, 'val') !== '0' && attr(e, 'val') !== 'false'; };
|
||||||
|
let t = '';
|
||||||
|
for (const c of r.children) {
|
||||||
|
if (c.localName === 't') t += esc(c.textContent);
|
||||||
|
else if (c.localName === 'tab') t += ' ';
|
||||||
|
else if (c.localName === 'br' || c.localName === 'cr') t += '<br>';
|
||||||
|
}
|
||||||
|
if (!t) return '';
|
||||||
|
if (on('b')) t = '<b>' + t + '</b>';
|
||||||
|
if (on('i')) t = '<i>' + t + '</i>';
|
||||||
|
if (kid(pr, 'u') && attr(kid(pr, 'u'), 'val') !== 'none') t = '<u>' + t + '</u>';
|
||||||
|
if (on('strike')) t = '<s>' + t + '</s>';
|
||||||
|
// A size set on the run itself, as many documents do instead of styles.
|
||||||
|
const sz = +attr(kid(pr, 'sz'), 'val');
|
||||||
|
if (sz && sz !== 24) t = '<span style="font-size:' + Math.max(9, Math.min(36, sz / 2)) + 'pt">' + t + '</span>';
|
||||||
|
const va = attr(kid(pr, 'vertAlign'), 'val');
|
||||||
|
if (va === 'superscript') t = '<sup>' + t + '</sup>';
|
||||||
|
else if (va === 'subscript') t = '<sub>' + t + '</sub>';
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
function table(tbl, styles) {
|
||||||
|
let html = '<div class="dv-scroll"><table class="dv-table">';
|
||||||
|
for (const tr of kids(tbl, 'tr')) {
|
||||||
|
html += '<tr>';
|
||||||
|
for (const tc of kids(tr, 'tc')) {
|
||||||
|
const span = attr(kid(kid(tc, 'tcPr'), 'gridSpan'), 'val');
|
||||||
|
html += '<td' + (span > 1 ? ' colspan="' + span + '"' : '') + '>' + blocks(tc, styles) + '</td>';
|
||||||
|
}
|
||||||
|
html += '</tr>';
|
||||||
|
}
|
||||||
|
return html + '</table></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- tables: one grid per sheet ----------
|
||||||
|
const MAX_ROWS = 2000, MAX_COLS = 60;
|
||||||
|
function colName(i) { let s = ''; for (i++; i > 0; i = Math.floor((i - 1) / 26)) s = String.fromCharCode(65 + (i - 1) % 26) + s; return s; }
|
||||||
|
function grid(rows, names) {
|
||||||
|
let cols = names ? names.length : 0;
|
||||||
|
for (const r of rows) cols = Math.max(cols, r.length);
|
||||||
|
cols = Math.min(cols, MAX_COLS);
|
||||||
|
const cut = rows.length > MAX_ROWS || rows.some(r => r.length > MAX_COLS);
|
||||||
|
let html = '<div class="dv-scroll dv-grid"><table class="dv-sheet"><thead><tr><th></th>';
|
||||||
|
for (let c = 0; c < cols; c++) html += '<th>' + esc(names && names[c] != null ? names[c] : colName(c)) + '</th>';
|
||||||
|
html += '</tr></thead><tbody>';
|
||||||
|
rows.slice(0, MAX_ROWS).forEach((r, i) => {
|
||||||
|
html += '<tr><th>' + (i + 1) + '</th>';
|
||||||
|
for (let c = 0; c < cols; c++) {
|
||||||
|
const v = r[c] == null ? '' : r[c];
|
||||||
|
html += '<td' + (typeof v === 'number' ? ' class="n"' : '') + '>' + esc(v) + '</td>';
|
||||||
|
}
|
||||||
|
html += '</tr>';
|
||||||
|
});
|
||||||
|
html += '</tbody></table></div>';
|
||||||
|
return {html, cut};
|
||||||
|
}
|
||||||
|
// Several sheets: tabs over the grid.
|
||||||
|
function sheets(list) {
|
||||||
|
if (!list.length) return '<p class="dv-note">в книге нет листов</p>';
|
||||||
|
let html = '';
|
||||||
|
if (list.length > 1) {
|
||||||
|
html += '<div class="dv-tabs">' + list.map((s, i) => '<button type="button" data-sheet="' + i + '"' + (i ? '' : ' class="on"') + '>' + esc(s.name) + '</button>').join('') + '</div>';
|
||||||
|
}
|
||||||
|
list.forEach((s, i) => {
|
||||||
|
const g = grid(s.rows, s.cols);
|
||||||
|
html += '<div class="dv-pane" data-pane="' + i + '"' + (i ? ' hidden' : '') + '>' + (s.note ? '<p class="dv-note">' + esc(s.note) + '</p>' : '') + g.html +
|
||||||
|
(g.cut ? '<p class="dv-note">показаны первые ' + MAX_ROWS + ' строк и ' + MAX_COLS + ' столбцов</p>' : '') + '</div>';
|
||||||
|
});
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
function bindTabs(root) {
|
||||||
|
root.querySelectorAll('[data-sheet]').forEach(b => b.onclick = () => {
|
||||||
|
root.querySelectorAll('[data-sheet]').forEach(x => x.classList.toggle('on', x === b));
|
||||||
|
root.querySelectorAll('[data-pane]').forEach(p => p.hidden = p.dataset.pane !== b.dataset.sheet);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- xlsx ----------
|
||||||
|
async function xlsx(buf) {
|
||||||
|
const z = openZip(buf);
|
||||||
|
const wb = await z.xml('xl/workbook.xml');
|
||||||
|
if (!wb) throw new Error('в архиве нет xl/workbook.xml');
|
||||||
|
const rels = {};
|
||||||
|
const rx = await z.xml('xl/_rels/workbook.xml.rels');
|
||||||
|
if (rx) for (const r of rx.getElementsByTagNameNS('*', 'Relationship')) rels[r.getAttribute('Id')] = r.getAttribute('Target');
|
||||||
|
const shared = [];
|
||||||
|
const ss = await z.xml('xl/sharedStrings.xml');
|
||||||
|
if (ss) for (const si of ss.getElementsByTagNameNS('*', 'si')) {
|
||||||
|
shared.push(Array.from(si.getElementsByTagNameNS('*', 't')).filter(t => t.parentElement.localName !== 'rPh').map(t => t.textContent).join(''));
|
||||||
|
}
|
||||||
|
const list = [];
|
||||||
|
for (const s of wb.getElementsByTagNameNS('*', 'sheet')) {
|
||||||
|
let target = rels[attr(s, 'id')] || '';
|
||||||
|
target = target.startsWith('/') ? target.slice(1) : 'xl/' + target.replace(/^\.\//, '');
|
||||||
|
const doc = await z.xml(target);
|
||||||
|
list.push({name: s.getAttribute('name') || '?', rows: doc ? xlsxRows(doc, shared) : []});
|
||||||
|
}
|
||||||
|
return sheets(list);
|
||||||
|
}
|
||||||
|
function xlsxRows(doc, shared) {
|
||||||
|
const rows = [];
|
||||||
|
for (const row of doc.getElementsByTagNameNS('*', 'row')) {
|
||||||
|
const ri = (+row.getAttribute('r') || rows.length + 1) - 1;
|
||||||
|
if (ri >= MAX_ROWS) break;
|
||||||
|
const out = rows[ri] = rows[ri] || [];
|
||||||
|
let next = 0;
|
||||||
|
for (const c of kids(row, 'c')) {
|
||||||
|
const ref = /^([A-Z]+)/.exec(c.getAttribute('r') || '');
|
||||||
|
let ci = next;
|
||||||
|
if (ref) { ci = 0; for (const ch of ref[1]) ci = ci * 26 + ch.charCodeAt(0) - 64; ci--; }
|
||||||
|
next = ci + 1;
|
||||||
|
if (ci >= MAX_COLS + 1) continue;
|
||||||
|
const t = c.getAttribute('t'), v = kid(c, 'v');
|
||||||
|
let val = v ? v.textContent : '';
|
||||||
|
if (t === 's') val = shared[+val] || '';
|
||||||
|
else if (t === 'inlineStr') val = Array.from(c.getElementsByTagNameNS('*', 't')).map(x => x.textContent).join('');
|
||||||
|
else if (t === 'b') val = val === '1' ? 'TRUE' : 'FALSE';
|
||||||
|
else if (t !== 'str' && t !== 'e' && val !== '' && !isNaN(val)) val = +val;
|
||||||
|
out[ci] = val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let i = 0; i < rows.length; i++) rows[i] = rows[i] || [];
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- ods / odt ----------
|
||||||
|
async function ods(buf) {
|
||||||
|
const z = openZip(buf);
|
||||||
|
const doc = await z.xml('content.xml');
|
||||||
|
if (!doc) throw new Error('в архиве нет content.xml');
|
||||||
|
const list = [];
|
||||||
|
for (const t of doc.getElementsByTagNameNS('*', 'table')) {
|
||||||
|
if (t.parentElement && t.parentElement.localName === 'table-cell') continue;
|
||||||
|
const rows = [];
|
||||||
|
for (const r of t.getElementsByTagNameNS('*', 'table-row')) {
|
||||||
|
const cells = [];
|
||||||
|
for (const c of r.children) {
|
||||||
|
if (c.localName !== 'table-cell' && c.localName !== 'covered-table-cell') continue;
|
||||||
|
const rep = Math.min(+attr(c, 'number-columns-repeated') || 1, MAX_COLS + 1 - cells.length);
|
||||||
|
const num = attr(c, 'value');
|
||||||
|
const text = Array.from(c.getElementsByTagNameNS('*', 'p')).map(p => p.textContent).join('\n');
|
||||||
|
const val = attr(c, 'value-type') === 'float' && num != null ? +num : text;
|
||||||
|
for (let i = 0; i < rep; i++) cells.push(val);
|
||||||
|
if (cells.length > MAX_COLS) break;
|
||||||
|
}
|
||||||
|
while (cells.length && cells[cells.length - 1] === '') cells.pop();
|
||||||
|
const rep = Math.min(+attr(r, 'number-rows-repeated') || 1, MAX_ROWS + 1 - rows.length);
|
||||||
|
for (let i = 0; i < rep; i++) rows.push(cells);
|
||||||
|
if (rows.length > MAX_ROWS) break;
|
||||||
|
}
|
||||||
|
while (rows.length && !rows[rows.length - 1].length) rows.pop();
|
||||||
|
list.push({name: attr(t, 'name') || '?', rows});
|
||||||
|
}
|
||||||
|
return sheets(list);
|
||||||
|
}
|
||||||
|
async function odt(buf) {
|
||||||
|
const z = openZip(buf);
|
||||||
|
const doc = await z.xml('content.xml');
|
||||||
|
const body = doc && doc.getElementsByTagNameNS('*', 'text')[0];
|
||||||
|
if (!body) throw new Error('в архиве нет текста');
|
||||||
|
// Runs of spaces, tabs and breaks are elements of their own in ODF.
|
||||||
|
const inline = el => {
|
||||||
|
let html = '';
|
||||||
|
for (const n of el.childNodes) {
|
||||||
|
if (n.nodeType === 3) html += esc(n.nodeValue);
|
||||||
|
else if (n.localName === 's') html += ' '.repeat(+attr(n, 'c') || 1);
|
||||||
|
else if (n.localName === 'tab') html += ' ';
|
||||||
|
else if (n.localName === 'line-break') html += '<br>';
|
||||||
|
else if (n.nodeType === 1) html += inline(n);
|
||||||
|
}
|
||||||
|
return html;
|
||||||
|
};
|
||||||
|
const walk = el => {
|
||||||
|
let html = '';
|
||||||
|
for (const c of el.children) {
|
||||||
|
if (c.localName === 'h') { const l = Math.min(6, (+attr(c, 'outline-level') || 1) + 1); html += '<h' + l + '>' + inline(c) + '</h' + l + '>'; }
|
||||||
|
else if (c.localName === 'p') html += '<p>' + (inline(c) || ' ') + '</p>';
|
||||||
|
else if (c.localName === 'list') html += '<ul>' + kids(c, 'list-item').map(li => '<li>' + walk(li) + '</li>').join('') + '</ul>';
|
||||||
|
else if (c.localName === 'table') html += '<div class="dv-scroll"><table class="dv-table">' + Array.from(c.getElementsByTagNameNS('*', 'table-row')).map(r =>
|
||||||
|
'<tr>' + kids(r, 'table-cell').map(td => '<td>' + walk(td) + '</td>').join('') + '</tr>').join('') + '</table></div>';
|
||||||
|
else if (c.localName === 'section') html += walk(c);
|
||||||
|
}
|
||||||
|
return html;
|
||||||
|
};
|
||||||
|
return '<div class="dv-doc">' + walk(body) + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- csv / tsv ----------
|
||||||
|
function csv(text, tab) {
|
||||||
|
const first = text.slice(0, text.indexOf('\n') >>> 0 || text.length);
|
||||||
|
const sep = tab ? '\t' : (first.split(';').length > first.split(',').length ? ';' : ',');
|
||||||
|
const rows = [];
|
||||||
|
let row = [], f = '', q = false;
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
const c = text[i];
|
||||||
|
if (q) {
|
||||||
|
if (c === '"') { if (text[i + 1] === '"') { f += '"'; i++; } else q = false; }
|
||||||
|
else f += c;
|
||||||
|
} else if (c === '"' && f === '') q = true;
|
||||||
|
else if (c === sep) { row.push(f); f = ''; }
|
||||||
|
else if (c === '\n' || c === '\r') {
|
||||||
|
if (c === '\r' && text[i + 1] === '\n') i++;
|
||||||
|
row.push(f); f = ''; rows.push(row); row = [];
|
||||||
|
if (rows.length > MAX_ROWS) break;
|
||||||
|
} else f += c;
|
||||||
|
}
|
||||||
|
if (f !== '' || row.length) { row.push(f); rows.push(row); }
|
||||||
|
return sheets([{name: '', rows: rows.map(r => r.map(v => v !== '' && !isNaN(v) && /^-?[\d.]+$/.test(v.trim()) ? +v : v))}]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- old .doc ----------
|
||||||
|
// Word 97-2003 is a binary format; without a full parser all that can be
|
||||||
|
// shown is the text, picked out of the document's WordDocument stream as
|
||||||
|
// the runs that read as words.
|
||||||
|
function cfbStream(b, want) {
|
||||||
|
const dv = new DataView(b.buffer, b.byteOffset, b.byteLength);
|
||||||
|
if (dv.getUint32(0, true) !== 0xE011CFD0) return null;
|
||||||
|
const ss = 1 << dv.getUint16(0x1E, true);
|
||||||
|
const sector = n => 512 + n * ss;
|
||||||
|
const fat = [];
|
||||||
|
const addFat = n => { const o = sector(n); for (let i = 0; i < ss / 4; i++) fat.push(dv.getUint32(o + i * 4, true)); };
|
||||||
|
for (let i = 0; i < 109; i++) { const n = dv.getUint32(0x4C + i * 4, true); if (n < 0xFFFFFFFA) addFat(n); }
|
||||||
|
for (let n = dv.getUint32(0x44, true), k = 0; n < 0xFFFFFFFA && k < 1000; k++) {
|
||||||
|
const o = sector(n);
|
||||||
|
for (let i = 0; i < ss / 4 - 1; i++) { const m = dv.getUint32(o + i * 4, true); if (m < 0xFFFFFFFA) addFat(m); }
|
||||||
|
n = dv.getUint32(o + ss - 4, true);
|
||||||
|
}
|
||||||
|
const chain = (start, size) => {
|
||||||
|
const out = new Uint8Array(size);
|
||||||
|
let n = start, at = 0;
|
||||||
|
for (let guard = 0; n < 0xFFFFFFFA && at < size && guard < 1e6; guard++) {
|
||||||
|
const o = sector(n), len = Math.min(ss, size - at);
|
||||||
|
if (o + len > b.length) break;
|
||||||
|
out.set(b.subarray(o, o + len), at);
|
||||||
|
at += len;
|
||||||
|
n = fat[n];
|
||||||
|
}
|
||||||
|
return out.subarray(0, at);
|
||||||
|
};
|
||||||
|
const dirStart = dv.getUint32(0x30, true);
|
||||||
|
const dir = chain(dirStart, ss * 64);
|
||||||
|
const dd = new DataView(dir.buffer, dir.byteOffset, dir.byteLength);
|
||||||
|
for (let o = 0; o + 128 <= dir.length; o += 128) {
|
||||||
|
const nameLen = dd.getUint16(o + 0x40, true);
|
||||||
|
let name = '';
|
||||||
|
for (let i = 0; i + 2 < nameLen; i += 2) name += String.fromCharCode(dd.getUint16(o + i, true));
|
||||||
|
if (name === want && dd.getUint8(o + 0x42) === 2) return chain(dd.getUint32(o + 0x74, true), dd.getUint32(o + 0x78, true));
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function doc(buf) {
|
||||||
|
const all = new Uint8Array(buf);
|
||||||
|
const b = cfbStream(all, 'WordDocument') || all;
|
||||||
|
const ok = c => c === 9 || c === 10 || c === 13 || (c >= 32 && c < 127) || (c >= 0xA0 && c <= 0x24F) || (c >= 0x400 && c <= 0x4FF) || (c >= 0x2010 && c <= 0x2026) || c === 0x2116;
|
||||||
|
const pieces = [];
|
||||||
|
let cur = '';
|
||||||
|
const flush = min => {
|
||||||
|
const letters = (cur.match(/[A-Za-zА-Яа-яЁё0-9]/g) || []).length;
|
||||||
|
if (cur.trim().length >= min && letters >= cur.trim().length * 0.5) pieces.push(cur);
|
||||||
|
cur = '';
|
||||||
|
};
|
||||||
|
for (let i = 0; i + 1 < b.length; i += 2) {
|
||||||
|
const c = b[i] | (b[i + 1] << 8);
|
||||||
|
if (ok(c)) cur += String.fromCharCode(c); else flush(3);
|
||||||
|
}
|
||||||
|
flush(4);
|
||||||
|
let text = pieces.join('\n');
|
||||||
|
if (text.length < 40) { // a document that keeps its text 8-bit
|
||||||
|
const p8 = [];
|
||||||
|
for (let i = 0; i < b.length; i++) {
|
||||||
|
const c = b[i];
|
||||||
|
if (c === 9 || c === 10 || c === 13 || (c >= 32 && c < 127)) cur += String.fromCharCode(c);
|
||||||
|
else { if (cur.trim().length >= 8 && (cur.match(/[A-Za-z]/g) || []).length >= cur.length * 0.5) p8.push(cur); cur = ''; }
|
||||||
|
}
|
||||||
|
text = p8.join('\n');
|
||||||
|
}
|
||||||
|
if (!text.trim()) throw new Error('текст не нашёлся');
|
||||||
|
return '<p class="dv-note">старый формат .doc: показан только текст, без оформления</p><div class="dv-doc">' +
|
||||||
|
text.split(/\r\n|\r|\n|\x07/).filter(l => l.trim()).map(l => '<p>' + esc(l) + '</p>').join('') + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- sqlite ----------
|
||||||
|
// Reads the database file itself: the b-tree of sqlite_master for the
|
||||||
|
// tables, then each table's b-tree for its first rows. No SQL engine.
|
||||||
|
function sqlite(buf) {
|
||||||
|
const b = new Uint8Array(buf);
|
||||||
|
if (new TextDecoder().decode(b.subarray(0, 15)) !== 'SQLite format 3') throw new Error('не база SQLite');
|
||||||
|
const dv = new DataView(b.buffer, b.byteOffset, b.byteLength);
|
||||||
|
let ps = dv.getUint16(16);
|
||||||
|
if (ps === 1) ps = 65536;
|
||||||
|
const usable = ps - b[20];
|
||||||
|
const enc = dv.getUint32(56);
|
||||||
|
const dec = new TextDecoder(enc === 2 ? 'utf-16le' : enc === 3 ? 'utf-16be' : 'utf-8');
|
||||||
|
const page = n => (n - 1) * ps;
|
||||||
|
|
||||||
|
function varint(o) {
|
||||||
|
let v = 0;
|
||||||
|
for (let i = 0; i < 9; i++) {
|
||||||
|
const c = b[o + i];
|
||||||
|
if (i === 8) return [v * 256 + c, o + 9];
|
||||||
|
v = v * 128 + (c & 0x7f);
|
||||||
|
if (!(c & 0x80)) return [v, o + i + 1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A cell's payload, gathered from its overflow pages when it spills.
|
||||||
|
function payload(o, size) {
|
||||||
|
const max = usable - 35;
|
||||||
|
if (size <= max) return b.subarray(o, o + size);
|
||||||
|
const min = Math.floor((usable - 12) * 32 / 255) - 23;
|
||||||
|
let local = min + (size - min) % (usable - 4);
|
||||||
|
if (local > max) local = min;
|
||||||
|
const out = new Uint8Array(size);
|
||||||
|
out.set(b.subarray(o, o + local));
|
||||||
|
let at = local, next = dv.getUint32(o + local);
|
||||||
|
for (let guard = 0; next && at < size && guard < 1e6; guard++) {
|
||||||
|
const po = page(next), len = Math.min(usable - 4, size - at);
|
||||||
|
out.set(b.subarray(po + 4, po + 4 + len), at);
|
||||||
|
at += len;
|
||||||
|
next = dv.getUint32(po);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function record(p) {
|
||||||
|
const pv = new DataView(p.buffer, p.byteOffset, p.byteLength);
|
||||||
|
let [hlen, o] = vi(p, 0);
|
||||||
|
const types = [];
|
||||||
|
while (o < hlen) { const r = vi(p, o); types.push(r[0]); o = r[1]; }
|
||||||
|
let d = hlen;
|
||||||
|
return types.map(t => {
|
||||||
|
let v = null;
|
||||||
|
if (t >= 1 && t <= 6) {
|
||||||
|
const n = [0, 1, 2, 3, 4, 6, 8][t];
|
||||||
|
let x = 0;
|
||||||
|
for (let i = 0; i < n; i++) x = x * 256 + p[d + i];
|
||||||
|
if (p[d] & 0x80) x -= Math.pow(2, 8 * n);
|
||||||
|
v = n === 8 && Math.abs(x) > Number.MAX_SAFE_INTEGER ? pv.getBigInt64(d).toString() : x;
|
||||||
|
d += n;
|
||||||
|
} else if (t === 7) { v = pv.getFloat64(d); d += 8; }
|
||||||
|
else if (t === 8) v = 0;
|
||||||
|
else if (t === 9) v = 1;
|
||||||
|
else if (t >= 12 && t % 2 === 0) { const n = (t - 12) / 2; v = '[blob ' + n + ' B]'; d += n; }
|
||||||
|
else if (t >= 13) { const n = (t - 13) / 2; v = dec.decode(p.subarray(d, d + n)); d += n; }
|
||||||
|
return v;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// varint over a payload buffer rather than the file
|
||||||
|
function vi(p, o) {
|
||||||
|
let v = 0;
|
||||||
|
for (let i = 0; i < 9; i++) {
|
||||||
|
const c = p[o + i];
|
||||||
|
if (i === 8) return [v * 256 + c, o + 9];
|
||||||
|
v = v * 128 + (c & 0x7f);
|
||||||
|
if (!(c & 0x80)) return [v, o + i + 1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Walks a table b-tree in rowid order, up to limit rows; also counts all.
|
||||||
|
function table(root, limit) {
|
||||||
|
const rows = [];
|
||||||
|
let count = 0;
|
||||||
|
const walk = (n, depth) => {
|
||||||
|
if (depth > 40) return;
|
||||||
|
const h = page(n) + (n === 1 ? 100 : 0);
|
||||||
|
const type = b[h], cells = dv.getUint16(h + 3);
|
||||||
|
const ptrs = h + (type === 0x05 ? 12 : 8);
|
||||||
|
if (type === 0x0d) {
|
||||||
|
count += cells;
|
||||||
|
for (let i = 0; i < cells && rows.length < limit; i++) {
|
||||||
|
let o = page(n) + dv.getUint16(ptrs + i * 2), size, rowid;
|
||||||
|
[size, o] = varint(o);
|
||||||
|
[rowid, o] = varint(o);
|
||||||
|
rows.push({rowid, values: record(payload(o, size))});
|
||||||
|
}
|
||||||
|
} else if (type === 0x05) {
|
||||||
|
for (let i = 0; i < cells; i++) walk(dv.getUint32(page(n) + dv.getUint16(ptrs + i * 2)), depth + 1);
|
||||||
|
walk(dv.getUint32(h + 8), depth + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(root, 0);
|
||||||
|
return {rows, count};
|
||||||
|
}
|
||||||
|
// Column names out of CREATE TABLE; an INTEGER PRIMARY KEY is the rowid.
|
||||||
|
function columns(sql) {
|
||||||
|
const m = /\(([\s\S]*)\)/.exec(sql || '');
|
||||||
|
if (!m) return {names: [], rowidCol: -1};
|
||||||
|
const parts = [];
|
||||||
|
let depth = 0, cur = '';
|
||||||
|
for (const c of m[1]) {
|
||||||
|
if (c === '(') depth++;
|
||||||
|
if (c === ')') depth--;
|
||||||
|
if (c === ',' && !depth) { parts.push(cur); cur = ''; } else cur += c;
|
||||||
|
}
|
||||||
|
parts.push(cur);
|
||||||
|
const names = [];
|
||||||
|
let rowidCol = -1;
|
||||||
|
for (const p of parts) {
|
||||||
|
const t = p.trim();
|
||||||
|
if (!t || /^(constraint|primary\s+key|unique|check|foreign\s+key)\b/i.test(t)) continue;
|
||||||
|
const nm = /^("(?:[^"]|"")+"|`[^`]+`|\[[^\]]+\]|\S+)/.exec(t)[1].replace(/^["`\[]|["`\]]$/g, '');
|
||||||
|
if (/^\S+\s+integer\s+primary\s+key\b/i.test(t.replace(/^("(?:[^"]|"")+"|`[^`]+`|\[[^\]]+\])/, 'x'))) rowidCol = names.length;
|
||||||
|
names.push(nm);
|
||||||
|
}
|
||||||
|
return {names, rowidCol};
|
||||||
|
}
|
||||||
|
|
||||||
|
const master = table(1, 100000).rows.map(r => ({type: r.values[0], name: r.values[1], root: r.values[3], sql: r.values[4] || ''}));
|
||||||
|
const list = [];
|
||||||
|
for (const t of master.filter(x => x.type === 'table' && x.root).sort((a, c) => String(a.name).localeCompare(String(c.name)))) {
|
||||||
|
if (/without\s+rowid/i.test(t.sql)) { list.push({name: t.name, rows: [], note: 'таблица WITHOUT ROWID - её строки здесь не показываются'}); continue; }
|
||||||
|
const {names, rowidCol} = columns(t.sql);
|
||||||
|
let data;
|
||||||
|
try { data = table(t.root, MAX_ROWS); } catch (e) { list.push({name: t.name, rows: [], note: 'не удалось прочитать: ' + e.message}); continue; }
|
||||||
|
const rows = data.rows.map(r => { const v = r.values.slice(); if (rowidCol >= 0 && v[rowidCol] == null) v[rowidCol] = r.rowid; return v; });
|
||||||
|
list.push({name: t.name, cols: names, rows, note: data.count + ' ' + (data.count % 10 === 1 && data.count % 100 !== 11 ? 'строка' : data.count % 10 >= 2 && data.count % 10 <= 4 && (data.count % 100 < 12 || data.count % 100 > 14) ? 'строки' : 'строк') + (data.count > rows.length ? ', показаны первые ' + rows.length : '')});
|
||||||
|
}
|
||||||
|
if (!list.length) return '<p class="dv-note">в базе нет таблиц</p>';
|
||||||
|
return sheets(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.DocView = {docx, xlsx, ods, odt, csv, doc, sqlite, bindTabs};
|
||||||
|
})();
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
// The PIN lock: in a new browser tab the panel asks for the admin's PIN
|
||||||
|
// before showing anything. The tab remembers it was unlocked (sessionStorage
|
||||||
|
// lives as long as the tab, through reloads, and a new tab starts empty), so
|
||||||
|
// a reload does not ask again. Loaded in <head>, before the page shows.
|
||||||
|
// A device can be set to trust the PIN for a while: for that many minutes
|
||||||
|
// after it was entered, new tabs there open without asking (localStorage,
|
||||||
|
// this browser only).
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
var KEY = 'lpra.pin.ok';
|
||||||
|
var ok = false;
|
||||||
|
try { ok = sessionStorage.getItem(KEY) === '1'; } catch (e) { /* no storage: ask */ }
|
||||||
|
if (ok) return;
|
||||||
|
try {
|
||||||
|
if (Date.now() < Number(localStorage.getItem('lpra.pin.until') || 0)) {
|
||||||
|
sessionStorage.setItem(KEY, '1');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) { /* ask */ }
|
||||||
|
var root = document.documentElement;
|
||||||
|
root.classList.add('pin-wait');
|
||||||
|
function release() { root.classList.remove('pin-wait'); }
|
||||||
|
function remember() { try { sessionStorage.setItem(KEY, '1'); } catch (e) { /* asked again next load */ } }
|
||||||
|
// After the PIN itself: no asking on this device for the minutes it trusts.
|
||||||
|
function trust() {
|
||||||
|
try {
|
||||||
|
var min = Number(localStorage.getItem('lpra.pin.grace') || 0);
|
||||||
|
if (min > 0) localStorage.setItem('lpra.pin.until', String(Date.now() + min * 60000));
|
||||||
|
} catch (e) { /* asks every tab */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch('/api/panel/pin', {cache: 'no-store'})
|
||||||
|
.then(function (r) { return r.ok ? r.json() : {pin: false}; })
|
||||||
|
.then(function (d) {
|
||||||
|
if (!d.pin) { remember(); release(); return; }
|
||||||
|
if (document.body) lock(); else document.addEventListener('DOMContentLoaded', lock);
|
||||||
|
})
|
||||||
|
.catch(release);
|
||||||
|
|
||||||
|
function lock() {
|
||||||
|
var LOCK = '<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="11" width="16" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg>';
|
||||||
|
var BACK = '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 5H9l-6 7 6 7h12a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1z"/><path d="m16 9-5 6M11 9l5 6"/></svg>';
|
||||||
|
var box = document.createElement('div');
|
||||||
|
box.className = 'pinlock';
|
||||||
|
box.setAttribute('role', 'dialog');
|
||||||
|
box.setAttribute('aria-modal', 'true');
|
||||||
|
box.setAttribute('aria-label', 'Ввод PIN-кода');
|
||||||
|
var keys = '';
|
||||||
|
['1', '2', '3', '4', '5', '6', '7', '8', '9', 'out', '0', 'back'].forEach(function (k) {
|
||||||
|
var label = k === 'back' ? ' aria-label="Стереть">' + BACK : k === 'out' ? ' title="Выйти из панели">Выйти' : '>' + k;
|
||||||
|
keys += '<button type="button" data-k="' + k + '"' + label + '</button>';
|
||||||
|
});
|
||||||
|
box.innerHTML =
|
||||||
|
'<div class="pin-card">' +
|
||||||
|
'<div class="pin-ico">' + LOCK + '</div>' +
|
||||||
|
'<div class="pin-title">Введите PIN-код</div>' +
|
||||||
|
'<div class="pin-dots"><i></i><i></i><i></i><i></i></div>' +
|
||||||
|
'<div class="pin-msg" aria-live="polite"></div>' +
|
||||||
|
'<div class="pin-pad">' + keys + '</div>' +
|
||||||
|
'<form method="post" action="/logout" class="pin-out" hidden></form>' +
|
||||||
|
'</div>';
|
||||||
|
document.body.appendChild(box);
|
||||||
|
requestAnimationFrame(function () { box.classList.add('on'); });
|
||||||
|
var dots = box.querySelectorAll('.pin-dots i'), msg = box.querySelector('.pin-msg'), card = box.querySelector('.pin-card');
|
||||||
|
var code = '', busy = false;
|
||||||
|
function show() {
|
||||||
|
for (var i = 0; i < 4; i++) dots[i].classList.toggle('on', i < code.length);
|
||||||
|
}
|
||||||
|
function press(k) {
|
||||||
|
if (k === 'out') { box.querySelector('.pin-out').submit(); return; }
|
||||||
|
if (busy) return;
|
||||||
|
if (k === 'back') { code = code.slice(0, -1); show(); return; }
|
||||||
|
if (code.length >= 4) return;
|
||||||
|
code += k;
|
||||||
|
msg.textContent = '';
|
||||||
|
card.classList.remove('bad');
|
||||||
|
show();
|
||||||
|
if (code.length === 4) check();
|
||||||
|
}
|
||||||
|
function check() {
|
||||||
|
busy = true;
|
||||||
|
fetch('/api/panel/pin/check', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({pin: code})})
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (r) {
|
||||||
|
if (r.ok) {
|
||||||
|
remember();
|
||||||
|
trust();
|
||||||
|
box.classList.add('done');
|
||||||
|
release();
|
||||||
|
setTimeout(function () { box.remove(); }, 320);
|
||||||
|
document.removeEventListener('keydown', onKey, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (r.out) { location.href = '/login'; return; }
|
||||||
|
card.classList.remove('bad');
|
||||||
|
void card.offsetWidth; // restart the shake
|
||||||
|
card.classList.add('bad');
|
||||||
|
msg.textContent = r.error + (r.left ? ' - осталось попыток: ' + r.left : '');
|
||||||
|
setTimeout(function () { code = ''; show(); busy = false; }, 380);
|
||||||
|
})
|
||||||
|
.catch(function () { msg.textContent = 'нет связи с сервером'; code = ''; show(); busy = false; });
|
||||||
|
}
|
||||||
|
box.querySelector('.pin-pad').addEventListener('click', function (ev) {
|
||||||
|
var b = ev.target.closest('button');
|
||||||
|
if (b) press(b.dataset.k);
|
||||||
|
});
|
||||||
|
// Typed digits, ahead of anything on the page (the terminal included).
|
||||||
|
function onKey(ev) {
|
||||||
|
if (/^[0-9]$/.test(ev.key)) press(ev.key);
|
||||||
|
else if (ev.key === 'Backspace') press('back');
|
||||||
|
else if (ev.key !== 'Tab') return;
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', onKey, true);
|
||||||
|
if (document.activeElement && document.activeElement.blur) document.activeElement.blur();
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// Mac-like scrollbars: a scrolled element shows its thin scrollbar, and so
|
||||||
|
// does one whose edge the pointer is at; after a second at rest it fades out.
|
||||||
|
// Elements with nothing to scroll never show one. The look is in app.css.
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
var live = new Map(); // element -> {v: shown 0..1, until: ms to stay}
|
||||||
|
var raf = 0;
|
||||||
|
function el(t) { return t === document || t === window ? document.documentElement : t; }
|
||||||
|
function scrollable(e) {
|
||||||
|
return e.scrollHeight > e.clientHeight + 1 || e.scrollWidth > e.clientWidth + 1;
|
||||||
|
}
|
||||||
|
function show(e, ms) {
|
||||||
|
if (!e || !e.style || !scrollable(e)) return;
|
||||||
|
var s = live.get(e) || {v: 0, until: 0};
|
||||||
|
s.until = Math.max(s.until, performance.now() + ms);
|
||||||
|
live.set(e, s);
|
||||||
|
if (!raf) raf = setTimeout(tick, 16);
|
||||||
|
}
|
||||||
|
var last = 0;
|
||||||
|
// A timer, not animation frames: those stop in a tab out of sight, and the
|
||||||
|
// bar would stay shown.
|
||||||
|
function tick() {
|
||||||
|
var now = performance.now();
|
||||||
|
var dt = Math.min(64, now - (last || now - 16));
|
||||||
|
last = now;
|
||||||
|
live.forEach(function (s, e) {
|
||||||
|
// In fast (about 0.12 s), out slowly (about 0.4 s).
|
||||||
|
var want = now < s.until ? 1 : 0;
|
||||||
|
s.v = want ? Math.min(1, s.v + dt / 120) : Math.max(0, s.v - dt / 400);
|
||||||
|
e.style.setProperty('--sb', s.v.toFixed(3));
|
||||||
|
if (!want && s.v === 0) { e.style.removeProperty('--sb'); live.delete(e); }
|
||||||
|
});
|
||||||
|
raf = live.size ? setTimeout(tick, 16) : 0;
|
||||||
|
if (!raf) last = 0;
|
||||||
|
}
|
||||||
|
document.addEventListener('scroll', function (ev) { show(el(ev.target), 900); }, {capture: true, passive: true});
|
||||||
|
// The pointer at a scrollable element's right or bottom edge shows its bar.
|
||||||
|
var moved = null;
|
||||||
|
document.addEventListener('pointermove', function (ev) {
|
||||||
|
if (ev.pointerType !== 'mouse') return;
|
||||||
|
if (!moved) requestAnimationFrame(function () {
|
||||||
|
var m = moved; moved = null;
|
||||||
|
for (var e = m.target; e && e.nodeType === 1; e = e.parentElement) {
|
||||||
|
if (!scrollable(e)) continue;
|
||||||
|
var st = getComputedStyle(e);
|
||||||
|
if (!/(auto|scroll)/.test(st.overflowY + st.overflowX) && e !== document.documentElement) continue;
|
||||||
|
var r = e === document.documentElement ? {right: innerWidth, bottom: innerHeight} : e.getBoundingClientRect();
|
||||||
|
if (m.x >= r.right - 14 || m.y >= r.bottom - 14) show(e, 700);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
moved = {target: ev.target, x: ev.clientX, y: ev.clientY};
|
||||||
|
}, {passive: true});
|
||||||
|
})();
|
||||||
@@ -0,0 +1,858 @@
|
|||||||
|
// Terminal page: shells on one or more machines, in tabs. With one tab the
|
||||||
|
// page is a plain terminal and a faint "+" in the corner; a second tab brings
|
||||||
|
// the tab strip in. On a computer a tab can be dragged onto the current one
|
||||||
|
// to share it - up to four terminals, split side by side or one above the
|
||||||
|
// other - and a terminal dragged out of it back to the strip. Splits resize
|
||||||
|
// by their gutters; a tab is renamed by a double click on its title.
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
const PAGE = window.TERM_PAGE;
|
||||||
|
const touch = matchMedia('(pointer: coarse)').matches;
|
||||||
|
const desk = !touch; // drag and drop, splits: computers only
|
||||||
|
if (touch) document.body.classList.add('touch');
|
||||||
|
const $ = id => document.getElementById(id);
|
||||||
|
const statusEl = $('status'), area = $('area'), tablist = $('tablist'), addBtn = $('tabadd'),
|
||||||
|
floatAdd = $('floatadd'), addMenu = $('addmenu'), hint = $('hint'), mark = $('tabmark');
|
||||||
|
const base = '/api/panel/term/';
|
||||||
|
const GAP = 6;
|
||||||
|
const X = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>';
|
||||||
|
|
||||||
|
function bytesToB64(bytes) {
|
||||||
|
let bin = '';
|
||||||
|
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
|
||||||
|
return btoa(bin);
|
||||||
|
}
|
||||||
|
function b64ToBytes(b64) {
|
||||||
|
const bin = atob(b64), u = new Uint8Array(bin.length);
|
||||||
|
for (let i = 0; i < bin.length; i++) u[i] = bin.charCodeAt(i);
|
||||||
|
return u;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- copy and paste by keys ----------
|
||||||
|
// Off a Mac, where Cmd does it, Ctrl+C copies when there is a selection and
|
||||||
|
// interrupts as ever when there is none; Ctrl+Shift+C always copies (and is
|
||||||
|
// kept from the browser, whose developer tools it would open); Ctrl+V and
|
||||||
|
// Ctrl+Shift+V paste. Keys go by position, so a Russian layout works too.
|
||||||
|
const mac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
|
||||||
|
function copyText(text) {
|
||||||
|
if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(text);
|
||||||
|
// Plain http: the old way, through a field that is there for a moment.
|
||||||
|
const ta = document.createElement('textarea'), back = document.activeElement;
|
||||||
|
ta.value = text;
|
||||||
|
ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0';
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.select();
|
||||||
|
const ok = document.execCommand('copy');
|
||||||
|
ta.remove();
|
||||||
|
if (back && back.focus) back.focus();
|
||||||
|
return ok ? Promise.resolve() : Promise.reject(new Error('нет доступа к буферу'));
|
||||||
|
}
|
||||||
|
function clipKeys(s, ev) {
|
||||||
|
if (mac || !ev.ctrlKey || ev.altKey || ev.metaKey) return true;
|
||||||
|
const c = ev.code === 'KeyC', v = ev.code === 'KeyV';
|
||||||
|
if (!c && !v) return true;
|
||||||
|
if (c && !ev.shiftKey && !s.term.hasSelection()) return true; // ^C to the shell
|
||||||
|
// From here the key is ours: xterm does not turn it into a control code.
|
||||||
|
if (ev.type === 'keydown' && c) {
|
||||||
|
ev.preventDefault();
|
||||||
|
if (s.term.hasSelection()) {
|
||||||
|
const text = s.term.getSelection();
|
||||||
|
s.term.clearSelection();
|
||||||
|
copyText(text).then(() => flashStatus('скопировано', 'st-ok'), e => flashStatus('не скопировалось: ' + e.message, 'st-err'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Ctrl+V: the browser's paste goes on into the terminal's text field.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
function flashStatus(t, cls) {
|
||||||
|
statusEl.textContent = t;
|
||||||
|
statusEl.className = cls;
|
||||||
|
clearTimeout(flashStatus.t);
|
||||||
|
flashStatus.t = setTimeout(showStatus, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- one shell ----------
|
||||||
|
class Session {
|
||||||
|
// ssh, when given, is where the agent connects by SSH for this shell:
|
||||||
|
// {host, port, user} and the secret to sign in with, sent once on open.
|
||||||
|
constructor(agent, ssh) {
|
||||||
|
this.agent = agent;
|
||||||
|
this.ssh = ssh || null;
|
||||||
|
this.sid = null; this.closed = false; this.connected = false;
|
||||||
|
this.state = 'wait'; this.text = 'подключение…';
|
||||||
|
this.inSeq = 0; this.inBusy = false; this.inQueue = [];
|
||||||
|
this.el = document.createElement('div');
|
||||||
|
this.el.className = 'pane';
|
||||||
|
this.el.innerHTML = '<div class="pane-head"><span class="dot wait"></span><span class="pane-title"></span>' +
|
||||||
|
'<button type="button" class="pane-x" title="Закрыть терминал" aria-label="Закрыть терминал">' + X + '</button></div>' +
|
||||||
|
'<div class="pane-body"></div>';
|
||||||
|
this.el.querySelector('.pane-title').textContent = who(this);
|
||||||
|
this.el.querySelector('.pane-title').title = ssh ? 'SSH через ' + agent.name : agent.name;
|
||||||
|
if (ssh) this.el.querySelector('.dot').insertAdjacentHTML('afterend', '<span class="badge-ssh">SSH</span>');
|
||||||
|
this.term = new Terminal({
|
||||||
|
cursorBlink: true, fontSize: touch ? 13 : 14,
|
||||||
|
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', theme: { background: '#0c0f13' }
|
||||||
|
});
|
||||||
|
this.fitter = new FitAddon.FitAddon();
|
||||||
|
this.term.loadAddon(this.fitter);
|
||||||
|
this.term.open(this.el.querySelector('.pane-body'));
|
||||||
|
this.term.attachCustomKeyEventHandler(ev => clipKeys(this, ev));
|
||||||
|
this.term.onData(d => this.send(TermKeys.withMods(d)));
|
||||||
|
this.term.onResize(() => this.queueIn({ t: 'r', cols: this.term.cols, rows: this.term.rows }));
|
||||||
|
this.term.onSelectionChange(() => TermKeys.selectionChanged(this));
|
||||||
|
this.el.addEventListener('pointerdown', () => setActivePane(this), true);
|
||||||
|
this.term.textarea.addEventListener('focus', () => setActivePane(this));
|
||||||
|
this.el.querySelector('.pane-x').onclick = ev => { ev.stopPropagation(); closePane(this); };
|
||||||
|
if (desk) this.el.querySelector('.pane-head').addEventListener('pointerdown', ev => startDrag(ev, 'pane', this));
|
||||||
|
this.open();
|
||||||
|
}
|
||||||
|
setState(state, text) {
|
||||||
|
this.state = state; this.text = text;
|
||||||
|
this.el.querySelector('.dot').className = 'dot ' + state;
|
||||||
|
const t = tabOf(this);
|
||||||
|
if (t) renderTab(t);
|
||||||
|
showStatus();
|
||||||
|
}
|
||||||
|
async open() {
|
||||||
|
try {
|
||||||
|
let opt = { method: 'POST' };
|
||||||
|
if (this.ssh) {
|
||||||
|
opt = { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ssh: this.ssh }) };
|
||||||
|
// The password or key goes once, to the agent; the page keeps no copy.
|
||||||
|
this.ssh = { host: this.ssh.host, port: this.ssh.port, user: this.ssh.user };
|
||||||
|
}
|
||||||
|
const r = await fetch(base + encodeURIComponent(this.agent.id) + '/open', opt);
|
||||||
|
if (!r.ok) {
|
||||||
|
let why = 'HTTP ' + r.status;
|
||||||
|
try { why = (await r.json()).error || why; } catch (e) { /* plain text */ }
|
||||||
|
this.setState('err', 'ошибка открытия: ' + why);
|
||||||
|
this.term.write('\r\n\x1b[31m[не удалось открыть сессию: ' + why + ']\x1b[0m\r\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.sid = (await r.json()).session_id;
|
||||||
|
this.setState('wait', 'ожидание агента…');
|
||||||
|
// An agent from before SSH terminals does not answer the request.
|
||||||
|
if (this.ssh) setTimeout(() => {
|
||||||
|
if (!this.closed && !this.ended && this.text === 'ожидание агента…') {
|
||||||
|
this.setState('err', 'агент не начал SSH-подключение');
|
||||||
|
this.term.write('\x1b[33mАгент не ответил. Если он старой версии, обновите его - SSH-терминал появился в новой.\x1b[0m\r\n');
|
||||||
|
}
|
||||||
|
}, 20000);
|
||||||
|
this.fit();
|
||||||
|
// A WebSocket if it connects; the long polls otherwise, and from a
|
||||||
|
// dropped WebSocket on for the rest of this session.
|
||||||
|
if (!(await this.openWS())) this.poll();
|
||||||
|
this.queueIn({ t: 'r', cols: this.term.cols, rows: this.term.rows });
|
||||||
|
} catch (e) {
|
||||||
|
this.setState('err', 'ошибка сети');
|
||||||
|
this.term.write('\r\n\x1b[31m[ошибка сети: ' + (e && e.message ? e.message : e) + ']\x1b[0m\r\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
openWS() {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
if (!window.WebSocket) { resolve(false); return; }
|
||||||
|
const ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + base + this.sid + '/ws');
|
||||||
|
let opened = false;
|
||||||
|
const timer = setTimeout(() => { if (!opened) { ws.close(); resolve(false); } }, 5000);
|
||||||
|
ws.onopen = () => { opened = true; clearTimeout(timer); this.ws = ws; resolve(true); };
|
||||||
|
ws.onmessage = ev => {
|
||||||
|
let j;
|
||||||
|
try { j = JSON.parse(ev.data); } catch (e) { return; }
|
||||||
|
this.take(j);
|
||||||
|
};
|
||||||
|
ws.onclose = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (!opened) { resolve(false); return; }
|
||||||
|
this.ws = null;
|
||||||
|
if (!this.closed && !this.ended) this.poll();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// take applies one answer of the output channel, either transport's.
|
||||||
|
take(j) {
|
||||||
|
if (j.closed) { this.finish('[сессия завершена]'); return false; }
|
||||||
|
for (const f of j.frames || []) {
|
||||||
|
if (f.status) this.sshStatus(f.status, f.note || '');
|
||||||
|
if (f.exit) { this.finish('[сессия завершена]'); return false; }
|
||||||
|
if (f.data) {
|
||||||
|
if (!this.connected && !this.ssh) { this.connected = true; this.setState('ok', 'подключено'); }
|
||||||
|
this.term.write(b64ToBytes(f.data));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// sshStatus shows how the agent's SSH connection goes.
|
||||||
|
sshStatus(st, note) {
|
||||||
|
if (st === 'dial') this.setState('wait', 'SSH: ' + note + '…');
|
||||||
|
else if (st === 'auth') this.setState('wait', 'SSH: ' + note + '…');
|
||||||
|
else if (st === 'ok') { this.connected = true; this.setState('ok', 'SSH: ' + note); }
|
||||||
|
else if (st === 'err') this.setState('err', 'SSH: ' + note);
|
||||||
|
}
|
||||||
|
async poll() {
|
||||||
|
while (!this.closed && this.sid) {
|
||||||
|
let r;
|
||||||
|
try { r = await fetch(base + this.sid + '/out', { cache: 'no-store' }); }
|
||||||
|
catch (e) { await new Promise(res => setTimeout(res, 1000)); continue; }
|
||||||
|
if (this.closed) return;
|
||||||
|
if (!r.ok) { this.finish('[соединение закрыто]'); return; }
|
||||||
|
if (!this.take(await r.json())) return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finish(msg) {
|
||||||
|
if (this.ended) return;
|
||||||
|
this.ended = true;
|
||||||
|
if (this.state !== 'err') this.setState('off', 'соединение закрыто');
|
||||||
|
this.term.write('\r\n\x1b[33m' + msg + '\x1b[0m\r\n');
|
||||||
|
}
|
||||||
|
// Input goes out one request at a time, so on a slow link a keystroke can
|
||||||
|
// never overtake the one before it; a request that fails goes again under
|
||||||
|
// the same number, and the server drops one it already has.
|
||||||
|
queueIn(m) {
|
||||||
|
if (this.closed || !this.sid) return;
|
||||||
|
const q = this.inQueue, last = q[q.length - 1];
|
||||||
|
if (last && last.t === 'i' && m.t === 'i') last.text += m.text;
|
||||||
|
else if (last && last.t === 'r' && m.t === 'r') q[q.length - 1] = m;
|
||||||
|
else q.push(m);
|
||||||
|
this.pumpIn();
|
||||||
|
}
|
||||||
|
async pumpIn() {
|
||||||
|
if (this.inBusy || !this.inQueue.length) return;
|
||||||
|
this.inBusy = true;
|
||||||
|
while (this.inQueue.length && !this.closed) {
|
||||||
|
const m = this.inQueue.shift();
|
||||||
|
const body = m.t === 'i'
|
||||||
|
? { t: 'i', d: bytesToB64(new TextEncoder().encode(m.text)), seq: ++this.inSeq }
|
||||||
|
: { t: 'r', cols: m.cols, rows: m.rows, seq: ++this.inSeq };
|
||||||
|
// On the WebSocket a message goes out in order with no answer to wait
|
||||||
|
// for; numbered as well, so a repeat by the long polls is dropped.
|
||||||
|
if (this.ws && this.ws.readyState === 1) {
|
||||||
|
try { this.ws.send(JSON.stringify(body)); continue; } catch (e) { /* by request below */ }
|
||||||
|
}
|
||||||
|
for (let wait = 300; !this.closed; wait = Math.min(wait * 2, 5000)) {
|
||||||
|
let r = null;
|
||||||
|
try {
|
||||||
|
r = await fetch(base + this.sid + '/in', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||||
|
} catch (e) { /* no answer: send it again */ }
|
||||||
|
if (r && r.status < 500) break;
|
||||||
|
await new Promise(res => setTimeout(res, wait));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.inBusy = false;
|
||||||
|
}
|
||||||
|
send(data) { this.queueIn({ t: 'i', text: data }); }
|
||||||
|
fit() {
|
||||||
|
const b = this.el.querySelector('.pane-body');
|
||||||
|
if (b.clientWidth > 20 && b.clientHeight > 20) { try { this.fitter.fit(); } catch (e) { /* not shown yet */ } }
|
||||||
|
}
|
||||||
|
close() {
|
||||||
|
if (this.closed) return;
|
||||||
|
this.closed = true;
|
||||||
|
if (this.ws) { try { this.ws.close(); } catch (e) { /* closed already */ } }
|
||||||
|
if (this.sid) fetch(base + this.sid + '/close', { method: 'POST', keepalive: true }).catch(() => {});
|
||||||
|
this.term.dispose();
|
||||||
|
this.el.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// who is what a shell is signed in as, for titles.
|
||||||
|
function who(s) {
|
||||||
|
return s.ssh ? s.ssh.user + '@' + s.ssh.host + (s.ssh.port && s.ssh.port !== 22 ? ':' + s.ssh.port : '') : s.agent.user + '@' + s.agent.host;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- tabs ----------
|
||||||
|
const tabs = [];
|
||||||
|
let active = null, activePane = null;
|
||||||
|
const tabOf = s => tabs.find(t => t.panes.includes(s));
|
||||||
|
|
||||||
|
function makeTab(panes, index) {
|
||||||
|
const t = { title: null, panes: panes, layout: panes.length === 1 ? '1' : 'h', c: 0.5, r: 0.5, gutters: [] };
|
||||||
|
t.view = document.createElement('div');
|
||||||
|
t.view.className = 'tabview';
|
||||||
|
area.insertBefore(t.view, hint);
|
||||||
|
panes.forEach(s => t.view.appendChild(s.el));
|
||||||
|
t.el = document.createElement('div');
|
||||||
|
t.el.className = 'tab enter';
|
||||||
|
t.el.innerHTML = '<span class="dot wait"></span><span class="tab-title"></span>' +
|
||||||
|
'<button type="button" class="tab-x" title="Закрыть вкладку" aria-label="Закрыть вкладку">' + X + '</button>';
|
||||||
|
t.el.addEventListener('click', ev => { if (!ev.target.closest('.tab-x, input') && !dragJustEnded) activate(t); });
|
||||||
|
t.el.querySelector('.tab-x').onclick = ev => { ev.stopPropagation(); closeTab(t); };
|
||||||
|
t.el.querySelector('.tab-title').addEventListener('dblclick', ev => { ev.stopPropagation(); rename(t); });
|
||||||
|
if (desk) t.el.addEventListener('pointerdown', ev => { if (!ev.target.closest('.tab-x, input')) startDrag(ev, 'tab', t); });
|
||||||
|
const at = index == null ? tabs.length : index;
|
||||||
|
tablist.insertBefore(t.el, tabs[at] ? tabs[at].el : mark);
|
||||||
|
tabs.splice(at, 0, t);
|
||||||
|
renderTab(t);
|
||||||
|
fitTabs();
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(() => t.el.classList.remove('enter')));
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
function tabTitle(t) {
|
||||||
|
if (t.title) return t.title;
|
||||||
|
const s = t.panes[0], name = s.ssh ? s.ssh.host : s.agent.name;
|
||||||
|
return t.panes.length > 1 ? name + ' +' + (t.panes.length - 1) : name;
|
||||||
|
}
|
||||||
|
// Tab widths: as wide as the title wants, from a comfortable minimum up to
|
||||||
|
// a maximum; when they do not fit, all give way evenly down to a floor,
|
||||||
|
// titles cut with "…", and below the floor the strip scrolls instead.
|
||||||
|
const ruler = document.createElement('canvas').getContext('2d');
|
||||||
|
const W = touch ? { min: 76, base: 124, max: 180, font: 12 } : { min: 96, base: 180, max: 300, font: 13 };
|
||||||
|
const CHROME = touch ? 52 : 62; // padding, dot, gaps, close button, borders
|
||||||
|
function fitTabs() {
|
||||||
|
ruler.font = W.font + 'px ' + getComputedStyle(document.body).fontFamily;
|
||||||
|
const live = tabs;
|
||||||
|
const want = live.map(t => Math.min(W.max, Math.max(W.base, Math.ceil(ruler.measureText(tabTitle(t)).width) + CHROME + (t.panes.some(p => p.ssh) ? 46 : 0))));
|
||||||
|
const strip = $('tabs');
|
||||||
|
const room = strip.clientWidth - parseFloat(getComputedStyle(strip).paddingLeft) * 2 - addBtn.offsetWidth - 4 * (live.length + 1);
|
||||||
|
let cap = W.max;
|
||||||
|
if (want.reduce((a, b) => a + b, 0) > room) {
|
||||||
|
// The widest give way first, to one width for all at the most.
|
||||||
|
let lo = W.min, hi = W.max;
|
||||||
|
for (let k = 0; k < 20; k++) {
|
||||||
|
const mid = (lo + hi) / 2;
|
||||||
|
if (want.reduce((a, b) => a + Math.min(b, mid), 0) > room) hi = mid; else lo = mid;
|
||||||
|
}
|
||||||
|
cap = Math.max(W.min, Math.floor(lo));
|
||||||
|
}
|
||||||
|
live.forEach((t, i) => { t.el.style.width = Math.min(want[i], cap) + 'px'; });
|
||||||
|
}
|
||||||
|
window.addEventListener('resize', () => fitTabs());
|
||||||
|
|
||||||
|
function renderTab(t) {
|
||||||
|
t.el.querySelector('.tab-title').textContent = tabTitle(t);
|
||||||
|
const ssh = t.panes.some(s => s.ssh);
|
||||||
|
if (ssh !== !!t.el.querySelector('.badge-ssh')) {
|
||||||
|
if (ssh) t.el.querySelector('.dot').insertAdjacentHTML('afterend', '<span class="badge-ssh">SSH</span>');
|
||||||
|
else t.el.querySelector('.badge-ssh').remove();
|
||||||
|
}
|
||||||
|
requestAnimationFrame(fitTabs);
|
||||||
|
t.el.title = t.panes.map(who).join(', ');
|
||||||
|
// The tab's dot: red if any shell failed, amber while one connects.
|
||||||
|
const st = t.panes.map(s => s.state);
|
||||||
|
t.el.querySelector('.dot').className = 'dot ' + (st.includes('err') ? 'err' : st.includes('wait') ? 'wait' : st.includes('ok') ? 'ok' : 'off');
|
||||||
|
t.el.classList.toggle('on', t === active);
|
||||||
|
t.view.classList.toggle('split', t.panes.length > 1);
|
||||||
|
syncFloat();
|
||||||
|
}
|
||||||
|
function activate(t) {
|
||||||
|
if (!t) return;
|
||||||
|
active = t;
|
||||||
|
tabs.forEach(x => { x.view.classList.toggle('on', x === t); renderTab(x); });
|
||||||
|
if (!t.panes.includes(activePane)) setActivePane(t.panes[0]);
|
||||||
|
layout(t, false);
|
||||||
|
t.el.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'smooth' });
|
||||||
|
if (desk && activePane) activePane.term.focus();
|
||||||
|
updateMode();
|
||||||
|
}
|
||||||
|
function setActivePane(s) {
|
||||||
|
activePane = s;
|
||||||
|
document.querySelectorAll('.pane.active').forEach(p => p.classList.toggle('active', p === s.el));
|
||||||
|
s.el.classList.add('active');
|
||||||
|
showStatus();
|
||||||
|
}
|
||||||
|
function showStatus() {
|
||||||
|
if (!activePane) { statusEl.textContent = ''; return; }
|
||||||
|
statusEl.textContent = activePane.text;
|
||||||
|
statusEl.title = activePane.text;
|
||||||
|
statusEl.className = activePane.state === 'ok' ? 'st-ok' : activePane.state === 'err' ? 'st-err' : 'muted';
|
||||||
|
}
|
||||||
|
// The corner "+" moves down out of the way of a split tab's title bars.
|
||||||
|
function syncFloat() {
|
||||||
|
document.body.classList.toggle('split1', tabs.length === 1 && tabs[0].panes.length > 1);
|
||||||
|
}
|
||||||
|
// One tab: a plain terminal, the strip folded into the corner "+".
|
||||||
|
function updateMode() {
|
||||||
|
syncFloat();
|
||||||
|
const multi = tabs.length > 1;
|
||||||
|
if (document.body.classList.contains('multi') === multi) return;
|
||||||
|
document.body.classList.toggle('multi', multi);
|
||||||
|
setTimeout(() => active && layout(active, false), 280);
|
||||||
|
}
|
||||||
|
function openTerminal(agent, ssh) {
|
||||||
|
$('empty').hidden = true;
|
||||||
|
const t = makeTab([new Session(agent, ssh)]);
|
||||||
|
activate(t);
|
||||||
|
}
|
||||||
|
function closeTab(t) {
|
||||||
|
const i = tabs.indexOf(t);
|
||||||
|
if (i < 0) return;
|
||||||
|
if (t.panes.length > 1 && !confirm('Закрыть вкладку с ' + t.panes.length + ' терминалами?')) return;
|
||||||
|
tabs.splice(i, 1);
|
||||||
|
t.panes.forEach(s => s.close());
|
||||||
|
t.el.classList.add('leave');
|
||||||
|
t.view.classList.remove('on');
|
||||||
|
setTimeout(() => { t.el.remove(); t.view.remove(); }, 220);
|
||||||
|
if (active === t) {
|
||||||
|
active = null;
|
||||||
|
activate(tabs[Math.min(i, tabs.length - 1)]);
|
||||||
|
}
|
||||||
|
if (!tabs.length) { activePane = null; showStatus(); $('empty').hidden = false; }
|
||||||
|
updateMode();
|
||||||
|
}
|
||||||
|
function rename(t) {
|
||||||
|
const span = t.el.querySelector('.tab-title');
|
||||||
|
const inp = document.createElement('input');
|
||||||
|
inp.className = 'tab-edit';
|
||||||
|
inp.value = tabTitle(t);
|
||||||
|
inp.size = Math.max(6, inp.value.length);
|
||||||
|
span.replaceWith(inp);
|
||||||
|
inp.focus(); inp.select();
|
||||||
|
let done = false;
|
||||||
|
const end = save => {
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
if (save) t.title = inp.value.trim() || null;
|
||||||
|
inp.replaceWith(span);
|
||||||
|
renderTab(t);
|
||||||
|
};
|
||||||
|
inp.onkeydown = ev => { if (ev.key === 'Enter') end(true); else if (ev.key === 'Escape') end(false); ev.stopPropagation(); };
|
||||||
|
inp.onblur = () => end(true);
|
||||||
|
inp.onpointerdown = ev => ev.stopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- splits ----------
|
||||||
|
// Where each terminal of a tab goes, and its gutters, for a layout:
|
||||||
|
// 1, h (side by side), v (one above the other), top2 (two over one),
|
||||||
|
// bottom2 (one over two), grid (two over two). c and r are where the
|
||||||
|
// column and the row divide.
|
||||||
|
function rects(layout, c, r, W, H) {
|
||||||
|
const g = GAP, cw = Math.round((W - g) * c), rh = Math.round((H - g) * r);
|
||||||
|
const top = (y, h) => [{ x: 0, y, w: cw, h }, { x: cw + g, y, w: W - cw - g, h }];
|
||||||
|
const colG = (y, h) => ({ k: 'c', x: cw, y, w: g, h });
|
||||||
|
const rowG = { k: 'r', x: 0, y: rh, w: W, h: g };
|
||||||
|
const bottomY = rh + g, bottomH = H - rh - g;
|
||||||
|
switch (layout) {
|
||||||
|
case 'h': return { panes: top(0, H), gutters: [colG(0, H)] };
|
||||||
|
case 'v': return { panes: [{ x: 0, y: 0, w: W, h: rh }, { x: 0, y: bottomY, w: W, h: bottomH }], gutters: [rowG] };
|
||||||
|
case 'top2': return { panes: top(0, rh).concat([{ x: 0, y: bottomY, w: W, h: bottomH }]), gutters: [colG(0, rh), rowG] };
|
||||||
|
case 'bottom2': return { panes: [{ x: 0, y: 0, w: W, h: rh }].concat(top(bottomY, bottomH)), gutters: [colG(bottomY, bottomH), rowG] };
|
||||||
|
case 'grid': return { panes: top(0, rh).concat(top(bottomY, bottomH)), gutters: [colG(0, H), rowG] };
|
||||||
|
default: return { panes: [{ x: 0, y: 0, w: W, h: H }], gutters: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function layout(t, animate) {
|
||||||
|
const W = t.view.clientWidth, H = t.view.clientHeight;
|
||||||
|
if (!W || !H) return;
|
||||||
|
const L = rects(t.layout, t.c, t.r, W, H);
|
||||||
|
t.view.classList.toggle('instant', !animate);
|
||||||
|
t.panes.forEach((s, i) => {
|
||||||
|
const p = L.panes[i];
|
||||||
|
s.el.style.left = p.x + 'px'; s.el.style.top = p.y + 'px';
|
||||||
|
s.el.style.width = p.w + 'px'; s.el.style.height = p.h + 'px';
|
||||||
|
});
|
||||||
|
while (t.gutters.length < L.gutters.length) {
|
||||||
|
const g = document.createElement('div');
|
||||||
|
g.className = 'gutter';
|
||||||
|
g.addEventListener('pointerdown', ev => startResize(ev, t, g));
|
||||||
|
t.view.appendChild(g);
|
||||||
|
t.gutters.push(g);
|
||||||
|
}
|
||||||
|
t.gutters.forEach((g, i) => {
|
||||||
|
const d = L.gutters[i];
|
||||||
|
g.hidden = !d;
|
||||||
|
if (!d) return;
|
||||||
|
g.className = 'gutter ' + d.k;
|
||||||
|
g.style.left = d.x + 'px'; g.style.top = d.y + 'px'; g.style.width = d.w + 'px'; g.style.height = d.h + 'px';
|
||||||
|
});
|
||||||
|
clearTimeout(t.fitTimer);
|
||||||
|
t.fitTimer = setTimeout(() => t.panes.forEach(s => s.fit()), animate ? 270 : 0);
|
||||||
|
}
|
||||||
|
function startResize(ev, t, g) {
|
||||||
|
ev.preventDefault();
|
||||||
|
g.setPointerCapture(ev.pointerId);
|
||||||
|
const box = t.view.getBoundingClientRect(), k = g.classList.contains('c') ? 'c' : 'r';
|
||||||
|
g.classList.add('drag');
|
||||||
|
let raf = 0;
|
||||||
|
const move = e => {
|
||||||
|
const v = k === 'c' ? (e.clientX - box.left) / box.width : (e.clientY - box.top) / box.height;
|
||||||
|
t[k] = Math.min(0.85, Math.max(0.15, v));
|
||||||
|
if (!raf) raf = requestAnimationFrame(() => { raf = 0; layout(t, false); });
|
||||||
|
};
|
||||||
|
const up = () => {
|
||||||
|
g.classList.remove('drag');
|
||||||
|
g.removeEventListener('pointermove', move);
|
||||||
|
g.removeEventListener('pointerup', up);
|
||||||
|
layout(t, false);
|
||||||
|
};
|
||||||
|
g.addEventListener('pointermove', move);
|
||||||
|
g.addEventListener('pointerup', up);
|
||||||
|
}
|
||||||
|
// A layout with one terminal fewer, the others keeping their places.
|
||||||
|
function withoutPane(layout, i) {
|
||||||
|
if (layout === 'h' || layout === 'v') return '1';
|
||||||
|
if (layout === 'top2') return i === 2 ? 'h' : 'v';
|
||||||
|
if (layout === 'bottom2') return i === 0 ? 'h' : 'v';
|
||||||
|
if (layout === 'grid') return i < 2 ? 'bottom2' : 'top2';
|
||||||
|
return '1';
|
||||||
|
}
|
||||||
|
function takePane(t, s) {
|
||||||
|
const i = t.panes.indexOf(s);
|
||||||
|
t.layout = withoutPane(t.layout, i);
|
||||||
|
t.panes.splice(i, 1);
|
||||||
|
}
|
||||||
|
function closePane(s) {
|
||||||
|
const t = tabOf(s);
|
||||||
|
if (!t) return;
|
||||||
|
if (t.panes.length === 1) { closeTab(t); return; }
|
||||||
|
takePane(t, s);
|
||||||
|
s.close();
|
||||||
|
if (activePane === s) setActivePane(t.panes[0]);
|
||||||
|
renderTab(t);
|
||||||
|
layout(t, true);
|
||||||
|
}
|
||||||
|
// Where a terminal dropped on tab t at (x, y) would go: the new layout,
|
||||||
|
// the order of its terminals ('P' the new one), and its place.
|
||||||
|
const SIDE = { left: 'слева', right: 'справа', top: 'сверху', bottom: 'снизу' };
|
||||||
|
function mergePlan(t, x, y) {
|
||||||
|
const n = t.panes.length, W = t.view.clientWidth, H = t.view.clientHeight;
|
||||||
|
const fx = x / W, fy = y / H;
|
||||||
|
let lay, order, label;
|
||||||
|
if (n === 1) {
|
||||||
|
const d = { left: fx, right: 1 - fx, top: fy, bottom: 1 - fy };
|
||||||
|
const k = Object.keys(d).reduce((a, b) => d[a] <= d[b] ? a : b);
|
||||||
|
lay = k === 'left' || k === 'right' ? 'h' : 'v';
|
||||||
|
order = k === 'left' || k === 'top' ? ['P', 0] : [0, 'P'];
|
||||||
|
label = SIDE[k];
|
||||||
|
} else if (n === 2 && t.layout === 'h') {
|
||||||
|
if (fy < 0.5) { lay = 'bottom2'; order = ['P', 0, 1]; label = 'сверху'; }
|
||||||
|
else { lay = 'top2'; order = [0, 1, 'P']; label = 'снизу'; }
|
||||||
|
} else if (n === 2) {
|
||||||
|
if (fy < 0.5) { lay = 'top2'; order = fx < 0.5 ? ['P', 0, 1] : [0, 'P', 1]; }
|
||||||
|
else { lay = 'bottom2'; order = fx < 0.5 ? [0, 'P', 1] : [0, 1, 'P']; }
|
||||||
|
label = (fy < 0.5 ? 'сверху ' : 'снизу ') + (fx < 0.5 ? 'слева' : 'справа');
|
||||||
|
} else if (n === 3) {
|
||||||
|
lay = 'grid';
|
||||||
|
if (t.layout === 'top2') { order = fx < 0.5 ? [0, 1, 'P', 2] : [0, 1, 2, 'P']; label = 'снизу ' + (fx < 0.5 ? 'слева' : 'справа'); }
|
||||||
|
else { order = fx < 0.5 ? ['P', 0, 1, 2] : [0, 'P', 1, 2]; label = 'сверху ' + (fx < 0.5 ? 'слева' : 'справа'); }
|
||||||
|
} else return null;
|
||||||
|
return { layout: lay, order, label, rect: rects(lay, t.c, t.r, W, H).panes[order.indexOf('P')] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- drag and drop ----------
|
||||||
|
// A tab: along the strip to reorder it, or onto the current tab to join
|
||||||
|
// it. A terminal from a split, by its title bar: to the strip to make it a
|
||||||
|
// tab of its own, or onto another terminal of its tab to swap places.
|
||||||
|
let drag = null, dragJustEnded = false;
|
||||||
|
function startDrag(ev, kind, obj) {
|
||||||
|
if (ev.button !== 0) return;
|
||||||
|
drag = { kind, obj, x0: ev.clientX, y0: ev.clientY, on: false, id: ev.pointerId };
|
||||||
|
}
|
||||||
|
function beginDrag() {
|
||||||
|
drag.on = true;
|
||||||
|
const src = drag.kind === 'tab' ? drag.obj.el : drag.obj.el.querySelector('.pane-head');
|
||||||
|
const g = document.createElement('div');
|
||||||
|
g.className = 'drag-ghost ' + drag.kind;
|
||||||
|
g.textContent = drag.kind === 'tab' ? tabTitle(drag.obj) : drag.obj.agent.user + '@' + drag.obj.agent.host;
|
||||||
|
document.body.appendChild(g);
|
||||||
|
drag.ghost = g;
|
||||||
|
drag.src = src;
|
||||||
|
src.classList.add('dragging');
|
||||||
|
document.body.classList.add('dnd');
|
||||||
|
// Pulling a terminal out: the strip shows, one tab or not, to drop it on.
|
||||||
|
if (drag.kind === 'pane') document.body.classList.add('multi-hint');
|
||||||
|
}
|
||||||
|
function stripIndex(x) {
|
||||||
|
const others = tabs.filter(t => !(drag.kind === 'tab' && t === drag.obj));
|
||||||
|
let i = others.findIndex(t => { const r = t.el.getBoundingClientRect(); return x < r.left + r.width / 2; });
|
||||||
|
if (i < 0) i = others.length;
|
||||||
|
const ref = others[i] ? others[i].el.getBoundingClientRect() : (others.length ? others[others.length - 1].el.getBoundingClientRect() : null);
|
||||||
|
const lr = tablist.getBoundingClientRect();
|
||||||
|
const mx = others[i] ? ref.left - 3 : ref ? ref.right + 1 : lr.left;
|
||||||
|
return { i, others, mx: mx - lr.left + tablist.scrollLeft };
|
||||||
|
}
|
||||||
|
function showHint(r, label, bad) {
|
||||||
|
hint.hidden = false;
|
||||||
|
hint.classList.toggle('bad', !!bad);
|
||||||
|
const v = active.view;
|
||||||
|
hint.style.left = (v.offsetLeft + r.x) + 'px'; hint.style.top = (v.offsetTop + r.y) + 'px';
|
||||||
|
hint.style.width = r.w + 'px'; hint.style.height = r.h + 'px';
|
||||||
|
hint.textContent = label;
|
||||||
|
}
|
||||||
|
function onDragMove(ev) {
|
||||||
|
if (!drag || ev.pointerId !== drag.id) return;
|
||||||
|
if (!drag.on) {
|
||||||
|
if (Math.hypot(ev.clientX - drag.x0, ev.clientY - drag.y0) < 6) return;
|
||||||
|
beginDrag();
|
||||||
|
}
|
||||||
|
drag.ghost.style.transform = 'translate(' + (ev.clientX + 10) + 'px,' + (ev.clientY + 8) + 'px)';
|
||||||
|
drag.target = null;
|
||||||
|
hint.hidden = true; mark.hidden = true;
|
||||||
|
const strip = $('tabs').getBoundingClientRect();
|
||||||
|
const stripShown = document.body.classList.contains('multi') || document.body.classList.contains('multi-hint');
|
||||||
|
const inStrip = stripShown && ev.clientY >= strip.top - 6 && ev.clientY <= strip.bottom + 10;
|
||||||
|
const inArea = active && (() => { const r = active.view.getBoundingClientRect(); return ev.clientX >= r.left && ev.clientX <= r.right && ev.clientY >= r.top && ev.clientY <= r.bottom; })();
|
||||||
|
if (inStrip) {
|
||||||
|
const s = stripIndex(ev.clientX);
|
||||||
|
mark.hidden = false; mark.style.left = s.mx + 'px';
|
||||||
|
drag.target = { kind: 'strip', index: s.i, others: s.others };
|
||||||
|
drag.ghost.dataset.hint = drag.kind === 'tab' ? 'переставить' : 'отдельной вкладкой';
|
||||||
|
} else if (inArea && drag.kind === 'tab' && drag.obj !== active) {
|
||||||
|
const r = active.view.getBoundingClientRect();
|
||||||
|
if (drag.obj.panes.length > 1) {
|
||||||
|
showHint({ x: 0, y: 0, w: r.width, h: r.height }, 'вкладку с несколькими терминалами не объединить', true);
|
||||||
|
} else if (active.panes.length >= 4) {
|
||||||
|
showHint({ x: 0, y: 0, w: r.width, h: r.height }, 'в одной вкладке не больше 4 терминалов', true);
|
||||||
|
} else {
|
||||||
|
const plan = mergePlan(active, ev.clientX - r.left, ev.clientY - r.top);
|
||||||
|
showHint(plan.rect, plan.label);
|
||||||
|
drag.target = { kind: 'merge', plan };
|
||||||
|
}
|
||||||
|
drag.ghost.dataset.hint = '';
|
||||||
|
} else if (inArea && drag.kind === 'pane' && tabOf(drag.obj) === active) {
|
||||||
|
const r = active.view.getBoundingClientRect();
|
||||||
|
const over = active.panes.find(s => { const p = s.el.getBoundingClientRect(); return s !== drag.obj && ev.clientX >= p.left && ev.clientX <= p.right && ev.clientY >= p.top && ev.clientY <= p.bottom; });
|
||||||
|
if (over) {
|
||||||
|
const p = over.el.getBoundingClientRect();
|
||||||
|
showHint({ x: p.left - r.left, y: p.top - r.top, w: p.width, h: p.height }, 'поменять местами');
|
||||||
|
drag.target = { kind: 'swap', with: over };
|
||||||
|
}
|
||||||
|
drag.ghost.dataset.hint = over ? '' : 'к вкладкам - отдельной вкладкой';
|
||||||
|
} else {
|
||||||
|
drag.ghost.dataset.hint = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onDragEnd(ev) {
|
||||||
|
if (!drag || ev.pointerId !== drag.id) return;
|
||||||
|
const d = drag;
|
||||||
|
drag = null;
|
||||||
|
if (!d.on) return;
|
||||||
|
d.ghost.remove();
|
||||||
|
d.src.classList.remove('dragging');
|
||||||
|
document.body.classList.remove('dnd', 'multi-hint');
|
||||||
|
hint.hidden = true; mark.hidden = true;
|
||||||
|
dragJustEnded = true;
|
||||||
|
setTimeout(() => { dragJustEnded = false; }, 0);
|
||||||
|
const tg = d.target;
|
||||||
|
if (!tg) return;
|
||||||
|
if (d.kind === 'tab' && tg.kind === 'strip') {
|
||||||
|
const t = d.obj, ref = tg.others[tg.index];
|
||||||
|
tabs.splice(tabs.indexOf(t), 1);
|
||||||
|
tabs.splice(ref ? tabs.indexOf(ref) : tabs.length, 0, t);
|
||||||
|
tablist.insertBefore(t.el, ref ? ref.el : mark);
|
||||||
|
} else if (d.kind === 'tab' && tg.kind === 'merge') {
|
||||||
|
const src = d.obj, s = src.panes[0], t = active, plan = tg.plan;
|
||||||
|
// The joining terminal starts where the hint showed it.
|
||||||
|
s.el.classList.add('instant');
|
||||||
|
s.el.style.left = plan.rect.x + 'px'; s.el.style.top = plan.rect.y + 'px';
|
||||||
|
s.el.style.width = plan.rect.w + 'px'; s.el.style.height = plan.rect.h + 'px';
|
||||||
|
t.view.appendChild(s.el);
|
||||||
|
requestAnimationFrame(() => s.el.classList.remove('instant'));
|
||||||
|
t.panes = plan.order.map(o => o === 'P' ? s : t.panes[o]);
|
||||||
|
t.layout = plan.layout;
|
||||||
|
tabs.splice(tabs.indexOf(src), 1);
|
||||||
|
src.el.classList.add('leave');
|
||||||
|
setTimeout(() => { src.el.remove(); src.view.remove(); }, 220);
|
||||||
|
setActivePane(s);
|
||||||
|
renderTab(t);
|
||||||
|
layout(t, true);
|
||||||
|
updateMode();
|
||||||
|
s.term.focus();
|
||||||
|
} else if (d.kind === 'pane' && tg.kind === 'strip') {
|
||||||
|
const s = d.obj, from = tabOf(s), ref = tg.others[tg.index];
|
||||||
|
takePane(from, s);
|
||||||
|
renderTab(from);
|
||||||
|
const t = makeTab([s], ref ? tabs.indexOf(ref) : tabs.length);
|
||||||
|
layout(from, true);
|
||||||
|
activate(t);
|
||||||
|
} else if (d.kind === 'pane' && tg.kind === 'swap') {
|
||||||
|
const t = active, a = t.panes.indexOf(d.obj), b = t.panes.indexOf(tg.with);
|
||||||
|
[t.panes[a], t.panes[b]] = [t.panes[b], t.panes[a]];
|
||||||
|
layout(t, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('pointermove', onDragMove);
|
||||||
|
window.addEventListener('pointerup', onDragEnd);
|
||||||
|
window.addEventListener('pointercancel', onDragEnd);
|
||||||
|
|
||||||
|
// ---------- the "+" menu ----------
|
||||||
|
let agentsCache = null;
|
||||||
|
function openMenu(anchor) {
|
||||||
|
const here = (activePane && activePane.agent) || PAGE.agent;
|
||||||
|
addMenu.innerHTML = '';
|
||||||
|
const item = (html, fn, cls) => {
|
||||||
|
const b = document.createElement('button');
|
||||||
|
b.type = 'button'; b.className = 'item ' + (cls || ''); b.innerHTML = html;
|
||||||
|
b.onclick = fn;
|
||||||
|
addMenu.appendChild(b);
|
||||||
|
return b;
|
||||||
|
};
|
||||||
|
const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||||
|
item('<span class="ico">+</span><span><b>Новый терминал здесь</b><small>' + esc(here.name) + ' · ' + esc(here.user + '@' + here.host) + '</small></span>',
|
||||||
|
() => { closeMenu(); openTerminal(here); });
|
||||||
|
item('<span class="ico">' + KEYI + '</span><span><b>SSH-подключение</b><small>агент откроет SSH к другому хосту</small></span>',
|
||||||
|
() => { closeMenu(); openSSH(here); });
|
||||||
|
const other = item('<span class="ico">⇄</span><span><b>На другой машине</b><small>выбрать из списка</small></span><span class="chev">›</span>', () => {
|
||||||
|
other.remove();
|
||||||
|
const list = document.createElement('div');
|
||||||
|
list.className = 'mlist';
|
||||||
|
list.innerHTML = '<div class="muted" style="padding:8px 10px;font-size:13px">загрузка…</div>';
|
||||||
|
addMenu.appendChild(list);
|
||||||
|
(agentsCache ? Promise.resolve(agentsCache) : fetch('/api/panel/agents/presence', { cache: 'no-store' }).then(r => r.json()))
|
||||||
|
.then(all => {
|
||||||
|
agentsCache = all;
|
||||||
|
list.innerHTML = '';
|
||||||
|
all.forEach(a => {
|
||||||
|
const b = document.createElement('button');
|
||||||
|
b.type = 'button'; b.className = 'item machine'; b.disabled = !a.online;
|
||||||
|
b.innerHTML = '<span class="dot ' + (a.online ? 'ok' : 'off') + '"></span><span><b>' + esc(a.name) + '</b><small>' +
|
||||||
|
esc(a.user + '@' + a.hostname) + (a.online ? '' : ' · ' + esc(a.seen)) + '</small></span>';
|
||||||
|
b.onclick = () => { closeMenu(); openTerminal({ id: a.id, name: a.name, host: a.hostname, user: a.user }); };
|
||||||
|
list.appendChild(b);
|
||||||
|
});
|
||||||
|
if (!all.length) list.innerHTML = '<div class="muted" style="padding:8px 10px">машин нет</div>';
|
||||||
|
placeMenu(anchor);
|
||||||
|
})
|
||||||
|
.catch(() => { list.innerHTML = '<div class="muted" style="padding:8px 10px">не загрузилось</div>'; });
|
||||||
|
});
|
||||||
|
addMenu.hidden = false;
|
||||||
|
placeMenu(anchor);
|
||||||
|
setTimeout(() => document.addEventListener('pointerdown', outside, true), 0);
|
||||||
|
}
|
||||||
|
function placeMenu(anchor) {
|
||||||
|
const r = anchor.getBoundingClientRect(), m = addMenu.getBoundingClientRect();
|
||||||
|
addMenu.style.left = Math.max(8, Math.min(innerWidth - m.width - 8, r.right - m.width)) + 'px';
|
||||||
|
addMenu.style.top = Math.min(innerHeight - m.height - 8, r.bottom + 6) + 'px';
|
||||||
|
}
|
||||||
|
function outside(ev) { if (!addMenu.contains(ev.target)) closeMenu(); }
|
||||||
|
function closeMenu() {
|
||||||
|
addMenu.hidden = true;
|
||||||
|
agentsCache = null;
|
||||||
|
document.removeEventListener('pointerdown', outside, true);
|
||||||
|
}
|
||||||
|
addBtn.onclick = () => openMenu(addBtn);
|
||||||
|
floatAdd.onclick = () => openMenu(floatAdd);
|
||||||
|
$('emptyadd').onclick = ev => openMenu(ev.currentTarget);
|
||||||
|
document.addEventListener('keydown', ev => { if (ev.key === 'Escape' && !addMenu.hidden) closeMenu(); });
|
||||||
|
|
||||||
|
// ---------- the SSH dialog ----------
|
||||||
|
const KEYI = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="7.5" cy="15.5" r="4.5"/><path d="m21 2-9.6 9.6M15.5 7.5l3 3L22 7l-3-3"/></svg>';
|
||||||
|
let sshDlg = null;
|
||||||
|
function openSSH(here) {
|
||||||
|
if (!sshDlg) sshDlg = makeSSHDialog();
|
||||||
|
sshDlg.show(here);
|
||||||
|
}
|
||||||
|
function makeSSHDialog() {
|
||||||
|
const d = document.createElement('dialog');
|
||||||
|
d.className = 'sshdlg';
|
||||||
|
d.innerHTML =
|
||||||
|
'<form method="dialog" autocomplete="off">' +
|
||||||
|
'<div class="dlg-h"><span class="ico">' + KEYI + '</span><strong style="flex:1">SSH-подключение</strong>' +
|
||||||
|
'<button type="button" class="btn ghost small icon close x" data-close aria-label="Закрыть">' + X + '</button></div>' +
|
||||||
|
'<div class="dlg-b">' +
|
||||||
|
'<label class="f"><span>Подключаться с машины</span><select name="agent"></select></label>' +
|
||||||
|
'<div class="frow"><label class="f grow"><span>Хост</span><input type="text" name="host" placeholder="example.com или 10.0.0.5" required autocapitalize="off" spellcheck="false"></label>' +
|
||||||
|
'<label class="f port"><span>Порт</span><input type="number" name="port" min="1" max="65535" value="22" required></label></div>' +
|
||||||
|
'<label class="f"><span>Пользователь</span><input type="text" name="user" placeholder="root" required autocapitalize="off" spellcheck="false"></label>' +
|
||||||
|
'<div class="f"><span>Вход</span><div class="seg" role="tablist">' +
|
||||||
|
'<button type="button" data-auth="password" class="on">Пароль</button><button type="button" data-auth="key">Ключ</button><button type="button" data-auth="agent">Ключи агента</button></div></div>' +
|
||||||
|
'<div class="auth" data-for="password"><input type="password" name="password" placeholder="пароль" autocomplete="off"></div>' +
|
||||||
|
'<div class="auth" data-for="key" hidden>' +
|
||||||
|
'<textarea name="key" rows="5" spellcheck="false" placeholder="-----BEGIN OPENSSH PRIVATE KEY----- …вставьте ключ или загрузите файл"></textarea>' +
|
||||||
|
'<div class="frow"><button type="button" class="btn ghost small" data-load>Загрузить файл…</button><span class="muted keyname"></span>' +
|
||||||
|
'<input type="file" hidden></div>' +
|
||||||
|
'<input type="password" name="passphrase" placeholder="фраза ключа, если есть" autocomplete="off"></div>' +
|
||||||
|
'<p class="auth muted" data-for="agent" hidden>Агент войдёт своими ключами из ~/.ssh (id_ed25519, id_ecdsa, id_rsa) без фразы.</p>' +
|
||||||
|
'<p class="sshmsg"></p>' +
|
||||||
|
'<div class="row end"><button type="button" class="btn ghost" data-close>Отмена</button><button type="submit">Подключиться</button></div>' +
|
||||||
|
'</div></form>';
|
||||||
|
document.body.appendChild(d);
|
||||||
|
const f = d.querySelector('form'), msg = d.querySelector('.sshmsg');
|
||||||
|
let auth = 'password';
|
||||||
|
const setAuth = a => {
|
||||||
|
auth = a;
|
||||||
|
d.querySelectorAll('.seg button').forEach(b => b.classList.toggle('on', b.dataset.auth === a));
|
||||||
|
d.querySelectorAll('.auth').forEach(el => { el.hidden = el.dataset.for !== a; });
|
||||||
|
};
|
||||||
|
d.querySelectorAll('.seg button').forEach(b => { b.onclick = () => setAuth(b.dataset.auth); });
|
||||||
|
d.querySelectorAll('[data-close]').forEach(b => { b.onclick = () => close(); });
|
||||||
|
const file = d.querySelector('input[type=file]');
|
||||||
|
d.querySelector('[data-load]').onclick = () => file.click();
|
||||||
|
file.onchange = () => {
|
||||||
|
const fl = file.files[0];
|
||||||
|
if (!fl) return;
|
||||||
|
if (fl.size > 32768) { msg.textContent = 'файл великоват для ключа'; return; }
|
||||||
|
fl.text().then(t => { f.key.value = t.trim() + '\n'; d.querySelector('.keyname').textContent = fl.name; msg.textContent = ''; });
|
||||||
|
file.value = '';
|
||||||
|
};
|
||||||
|
// then runs once the dialog is shut and focus is back where it was, so
|
||||||
|
// what it focuses stays focused.
|
||||||
|
function close(then) {
|
||||||
|
d.close();
|
||||||
|
if (then) then();
|
||||||
|
}
|
||||||
|
d.addEventListener('cancel', ev => { ev.preventDefault(); close(); });
|
||||||
|
d.addEventListener('click', ev => { if (ev.target === d) close(); });
|
||||||
|
let agents = [];
|
||||||
|
f.onsubmit = ev => {
|
||||||
|
ev.preventDefault();
|
||||||
|
const a = agents.find(x => x.id === f.agent.value);
|
||||||
|
if (!a) { msg.textContent = 'выберите машину'; return; }
|
||||||
|
const t = { host: f.host.value.trim(), port: Number(f.port.value) || 22, user: f.user.value.trim() };
|
||||||
|
if (auth === 'password') {
|
||||||
|
if (!f.password.value) { msg.textContent = 'введите пароль'; f.password.focus(); return; }
|
||||||
|
t.password = f.password.value;
|
||||||
|
} else if (auth === 'key') {
|
||||||
|
if (!/PRIVATE KEY/.test(f.key.value)) { msg.textContent = 'вставьте закрытый ключ или загрузите файл'; return; }
|
||||||
|
t.key = f.key.value;
|
||||||
|
if (f.passphrase.value) t.passphrase = f.passphrase.value;
|
||||||
|
}
|
||||||
|
try { localStorage.setItem('lpra.ssh.last', JSON.stringify({ agent: a.id, host: t.host, port: t.port, user: t.user, auth: auth })); } catch (e) { /* not kept */ }
|
||||||
|
f.password.value = ''; f.passphrase.value = ''; f.key.value = '';
|
||||||
|
d.querySelector('.keyname').textContent = '';
|
||||||
|
close(() => openTerminal(a, t));
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
show(here) {
|
||||||
|
msg.textContent = '';
|
||||||
|
let last = {};
|
||||||
|
try { last = JSON.parse(localStorage.getItem('lpra.ssh.last') || '{}'); } catch (e) { /* none */ }
|
||||||
|
agents = [here];
|
||||||
|
const fill = () => {
|
||||||
|
f.agent.innerHTML = '';
|
||||||
|
agents.forEach(a => {
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = a.id; o.textContent = a.name + ' · ' + a.user + '@' + a.host + (a.online === false ? ' (не в сети)' : '');
|
||||||
|
o.disabled = a.online === false;
|
||||||
|
f.agent.appendChild(o);
|
||||||
|
});
|
||||||
|
f.agent.value = agents.some(a => a.id === last.agent && a.online !== false) ? last.agent : here.id;
|
||||||
|
};
|
||||||
|
fill();
|
||||||
|
fetch('/api/panel/agents/presence', { cache: 'no-store' }).then(r => r.json()).then(all => {
|
||||||
|
agents = all.map(a => ({ id: a.id, name: a.name, host: a.hostname, user: a.user, online: a.online }));
|
||||||
|
if (!agents.some(a => a.id === here.id)) agents.unshift(here);
|
||||||
|
fill();
|
||||||
|
}).catch(() => {});
|
||||||
|
if (last.host) { f.host.value = last.host; f.port.value = last.port || 22; f.user.value = last.user || ''; }
|
||||||
|
setAuth(last.auth === 'key' || last.auth === 'agent' ? last.auth : 'password');
|
||||||
|
d.showModal();
|
||||||
|
// On a phone the keyboard waits for a tap.
|
||||||
|
if (desk) (last.host ? (last.auth === 'password' || !last.auth ? f.password : f.host) : f.host).focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- page ----------
|
||||||
|
function refit() {
|
||||||
|
if (touch && window.visualViewport) {
|
||||||
|
// Follow the visible area, so the extra keys sit right above the
|
||||||
|
// on-screen keyboard.
|
||||||
|
const vv = window.visualViewport;
|
||||||
|
document.body.style.top = vv.offsetTop + 'px';
|
||||||
|
document.body.style.height = vv.height + 'px';
|
||||||
|
}
|
||||||
|
if (active) layout(active, false);
|
||||||
|
}
|
||||||
|
window.addEventListener('resize', refit);
|
||||||
|
if (touch && window.visualViewport) {
|
||||||
|
window.visualViewport.addEventListener('resize', refit);
|
||||||
|
window.visualViewport.addEventListener('scroll', refit);
|
||||||
|
}
|
||||||
|
window.addEventListener('pagehide', () => {
|
||||||
|
tabs.forEach(t => t.panes.forEach(s => { if (s.sid) navigator.sendBeacon(base + s.sid + '/close'); }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// For the touch helpers in the page: the terminal being typed in.
|
||||||
|
window.TermPane = { active: () => activePane };
|
||||||
|
openTerminal(PAGE.agent);
|
||||||
|
refit();
|
||||||
|
})();
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
// The users table in Settings: the role, the machines a user may use (a
|
||||||
|
// dialog with a list to tick), the password sign-in and whether the user
|
||||||
|
// may switch it themselves. Each change is saved at once.
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
var table = document.querySelector('table.users');
|
||||||
|
if (!table) return;
|
||||||
|
var $ = function (id) { return document.getElementById(id); };
|
||||||
|
|
||||||
|
function save(id, body) {
|
||||||
|
return fetch('/api/panel/users/' + id, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)})
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.catch(function () { return {ok: false, error: 'ошибка сети'}; });
|
||||||
|
}
|
||||||
|
function note(tr, text, bad) {
|
||||||
|
var n = tr.querySelector('.u-note');
|
||||||
|
if (!n) {
|
||||||
|
n = document.createElement('div');
|
||||||
|
n.className = 'u-note';
|
||||||
|
tr.cells[0].appendChild(n);
|
||||||
|
}
|
||||||
|
n.textContent = text;
|
||||||
|
n.classList.toggle('bad', !!bad);
|
||||||
|
n.classList.remove('fade');
|
||||||
|
void n.offsetWidth;
|
||||||
|
n.classList.add('fade');
|
||||||
|
}
|
||||||
|
|
||||||
|
table.addEventListener('change', function (ev) {
|
||||||
|
var tr = ev.target.closest('tr'), id = tr && tr.dataset.id, el = ev.target;
|
||||||
|
if (!id) return;
|
||||||
|
var body = {}, undo;
|
||||||
|
if (el.classList.contains('u-role')) {
|
||||||
|
body.role = el.value;
|
||||||
|
undo = function () { el.value = el.value === 'user' ? 'admin' : 'user'; };
|
||||||
|
} else if (el.classList.contains('u-pw')) {
|
||||||
|
body.password_off = !el.checked;
|
||||||
|
undo = function () { el.checked = !el.checked; };
|
||||||
|
} else if (el.classList.contains('u-lock')) {
|
||||||
|
body.pw_locked = el.checked;
|
||||||
|
undo = function () { el.checked = !el.checked; };
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
save(id, body).then(function (r) {
|
||||||
|
if (!r.ok) { undo(); note(tr, r.error || 'не сохранилось', true); return; }
|
||||||
|
note(tr, 'сохранено');
|
||||||
|
if (body.role) {
|
||||||
|
var user = body.role === 'user';
|
||||||
|
tr.querySelector('.u-agents').hidden = !user;
|
||||||
|
tr.querySelector('.u-count').hidden = !user;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- the machines of a user ----
|
||||||
|
var dlg = $('uadlg'), list = $('ualist'), cur = null;
|
||||||
|
function render(filter) {
|
||||||
|
var f = (filter || '').toLowerCase();
|
||||||
|
list.querySelectorAll('label').forEach(function (l) {
|
||||||
|
l.hidden = f && l.textContent.toLowerCase().indexOf(f) < 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
table.addEventListener('click', function (ev) {
|
||||||
|
var b = ev.target.closest('.u-agents');
|
||||||
|
if (!b) return;
|
||||||
|
var tr = b.closest('tr');
|
||||||
|
cur = tr;
|
||||||
|
$('uaname').textContent = tr.dataset.name;
|
||||||
|
list.innerHTML = '<div class="muted">загрузка…</div>';
|
||||||
|
$('uafilter').value = '';
|
||||||
|
dlg.showModal();
|
||||||
|
fetch('/api/panel/users/' + tr.dataset.id + '/agents', {cache: 'no-store'}).then(function (r) { return r.json(); }).then(function (d) {
|
||||||
|
list.innerHTML = '';
|
||||||
|
d.agents.forEach(function (a) {
|
||||||
|
var l = document.createElement('label');
|
||||||
|
l.className = 'ua-item';
|
||||||
|
l.innerHTML = '<input type="checkbox"><span class="dot ' + (a.online ? 'on' : 'off') + '"></span><span class="ua-name"></span><span class="muted ua-host"></span>';
|
||||||
|
l.querySelector('input').value = a.id;
|
||||||
|
l.querySelector('input').checked = a.on;
|
||||||
|
l.querySelector('.ua-name').textContent = a.name;
|
||||||
|
l.querySelector('.ua-host').textContent = a.hostname;
|
||||||
|
list.appendChild(l);
|
||||||
|
});
|
||||||
|
if (!d.agents.length) list.innerHTML = '<div class="muted">машин пока нет</div>';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
$('uafilter').addEventListener('input', function (ev) { render(ev.target.value); });
|
||||||
|
$('uaall').onclick = function () { list.querySelectorAll('label:not([hidden]) input').forEach(function (i) { i.checked = true; }); };
|
||||||
|
$('uanone').onclick = function () { list.querySelectorAll('label:not([hidden]) input').forEach(function (i) { i.checked = false; }); };
|
||||||
|
$('uaclose').onclick = $('uacancel').onclick = function () { dlg.close(); };
|
||||||
|
$('uasave').onclick = function () {
|
||||||
|
var ids = [].map.call(list.querySelectorAll('input:checked'), function (i) { return i.value; });
|
||||||
|
save(cur.dataset.id, {agents: ids}).then(function (r) {
|
||||||
|
if (!r.ok) { note(cur, r.error || 'не сохранилось', true); return; }
|
||||||
|
cur.querySelector('.u-count').textContent = ids.length;
|
||||||
|
note(cur, 'машин: ' + ids.length);
|
||||||
|
dlg.close();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- a new user: the machines appear for a plain user ----
|
||||||
|
var role = $('newrole'), pick = $('newagents');
|
||||||
|
if (role) role.onchange = function () { pick.hidden = role.value !== 'user'; };
|
||||||
|
})();
|
||||||
@@ -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>
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||||
@@ -27,22 +155,50 @@
|
|||||||
.keys button:active { background:var(--border); }
|
.keys button:active { background:var(--border); }
|
||||||
.keys button.on { background:var(--accent); border-color:var(--accent); color:#fff; }
|
.keys button.on { background:var(--accent); border-color:var(--accent); color:#fff; }
|
||||||
.keys button.lock { background:var(--accent); border-color:#fff; color:#fff; }
|
.keys button.lock { background:var(--accent); border-color:#fff; color:#fff; }
|
||||||
|
/* Copy and paste on touch screens: a long press selects, this menu acts. */
|
||||||
|
.selmenu { position:fixed; left:50%; transform:translateX(-50%); z-index:20; display:flex; gap:7px; padding:5px;
|
||||||
|
background:var(--panel); border:1px solid var(--border); border-radius:8px; box-shadow:0 6px 24px rgba(0,0,0,.5); }
|
||||||
|
.selmenu[hidden] { display:none; }
|
||||||
|
.selmenu button { width:42px; height:42px; padding:0; display:inline-flex; align-items:center; justify-content:center;
|
||||||
|
touch-action:manipulation; -webkit-tap-highlight-color:transparent; }
|
||||||
|
body.touch .area { -webkit-user-select:none; user-select:none; -webkit-touch-callout:none; }
|
||||||
</style>
|
</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 class="area" id="area">
|
||||||
|
<button type="button" class="float-add" id="floatadd" title="Новый терминал" aria-label="Новый терминал">+</button>
|
||||||
|
<div class="empty" id="empty" hidden>Нет открытых терминалов<button type="button" id="emptyadd">Новый терминал</button></div>
|
||||||
|
<div class="hint" id="hint" hidden></div>
|
||||||
|
</div>
|
||||||
|
<div class="addmenu" id="addmenu" hidden></div>
|
||||||
|
<div class="selmenu" id="selmenu" hidden>
|
||||||
|
<button type="button" data-a="copy" title="Копировать" aria-label="Копировать">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"/></svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn ghost" data-a="paste" title="Вставить" aria-label="Вставить">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="8" y="2" width="8" height="4" rx="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/></svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn ghost" data-a="all" title="Выделить всё" aria-label="Выделить всё">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 3a2 2 0 0 0-2 2M19 3a2 2 0 0 1 2 2M21 19a2 2 0 0 1-2 2M5 21a2 2 0 0 1-2-2M9 3h1M9 21h1M14 3h1M14 21h1M3 9v1M21 9v1M3 14v1M21 14v1"/><rect x="7" y="7" width="10" height="10" rx="1"/></svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn ghost close" data-a="close" title="Закрыть" aria-label="Закрыть">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="keys" id="keys">
|
<div class="keys" id="keys">
|
||||||
<button type="button" data-k="esc">ESC</button>
|
<button type="button" data-k="esc">ESC</button>
|
||||||
@@ -61,116 +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');
|
|
||||||
}
|
|
||||||
|
|
||||||
function send(data) {
|
|
||||||
if (closed || !sid) return;
|
|
||||||
const bytes = new TextEncoder().encode(data);
|
|
||||||
post(base + sid + '/in', { t: 'i', d: bytesToB64(bytes) });
|
|
||||||
}
|
|
||||||
// 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
|
||||||
@@ -203,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];
|
||||||
@@ -234,41 +289,135 @@
|
|||||||
}
|
}
|
||||||
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() {
|
// --- Selecting and copying on touch screens ---
|
||||||
if (closed || !sid) return;
|
// xterm.js has no touch selection of its own. A long press selects the word
|
||||||
post(base + sid + '/in', { t: 'r', cols: term.cols, rows: term.rows });
|
// under the finger and dragging on from there stretches the selection; a
|
||||||
|
// menu then copies it or pastes. A plain tap or swipe still types and scrolls.
|
||||||
|
const menu = document.getElementById('selmenu');
|
||||||
|
let selecting = false;
|
||||||
|
function showMenu() {
|
||||||
|
const s = cur();
|
||||||
|
const keys = document.getElementById('keys').getBoundingClientRect();
|
||||||
|
menu.style.top = '';
|
||||||
|
menu.style.bottom = (window.innerHeight - keys.top + 14) + 'px';
|
||||||
|
menu.querySelector('[data-a="copy"]').disabled = !(s && s.term.hasSelection());
|
||||||
|
menu.hidden = false;
|
||||||
}
|
}
|
||||||
function refit() {
|
function selectionChanged(s) {
|
||||||
if (touch && window.visualViewport) {
|
if (!touch || s !== cur()) return;
|
||||||
// Follow the visible area, so the extra keys sit right above the
|
if (s.term.hasSelection()) showMenu();
|
||||||
// on-screen keyboard.
|
else if (!selecting) menu.hidden = true;
|
||||||
const vv = window.visualViewport;
|
|
||||||
document.body.style.top = vv.offsetTop + 'px';
|
|
||||||
document.body.style.height = vv.height + 'px';
|
|
||||||
}
|
}
|
||||||
fit.fit();
|
if (touch) {
|
||||||
|
const box = document.getElementById('area');
|
||||||
|
let timer = null, start = null, anchor = null;
|
||||||
|
function cellAt(term, el, x, y) {
|
||||||
|
const r = el.querySelector('.xterm-screen').getBoundingClientRect();
|
||||||
|
const col = Math.max(0, Math.min(term.cols - 1, Math.floor((x - r.left) / (r.width / term.cols))));
|
||||||
|
const row = Math.max(0, Math.min(term.rows - 1, Math.floor((y - r.top) / (r.height / term.rows))));
|
||||||
|
return { col: col, row: row + term.buffer.active.viewportY };
|
||||||
}
|
}
|
||||||
window.addEventListener('resize', refit);
|
function selectWord(term, c) {
|
||||||
if (touch && window.visualViewport) {
|
const line = term.buffer.active.getLine(c.row);
|
||||||
window.visualViewport.addEventListener('resize', refit);
|
const text = line ? line.translateToString(false) : '';
|
||||||
window.visualViewport.addEventListener('scroll', refit);
|
const word = ch => ch && !/\s/.test(ch);
|
||||||
|
let a = c.col, b = c.col;
|
||||||
|
if (word(text[c.col])) {
|
||||||
|
while (a > 0 && word(text[a - 1])) a--;
|
||||||
|
while (b < text.length - 1 && word(text[b + 1])) b++;
|
||||||
}
|
}
|
||||||
refit();
|
term.select(a, c.row, b - a + 1);
|
||||||
term.onResize(sendResize);
|
anchor = { from: c.row * term.cols + a, to: c.row * term.cols + b };
|
||||||
|
}
|
||||||
|
function stretch(term, c) {
|
||||||
|
const p = c.row * term.cols + c.col;
|
||||||
|
const from = Math.min(anchor.from, p), to = Math.max(anchor.to, p);
|
||||||
|
term.select(from % term.cols, Math.floor(from / term.cols), to - from + 1);
|
||||||
|
}
|
||||||
|
function hideMenu() { menu.hidden = true; const s = cur(); if (s) s.term.clearSelection(); anchor = null; }
|
||||||
|
box.addEventListener('touchstart', function (e) {
|
||||||
|
if (e.touches.length !== 1 || !e.target.closest('.pane-body')) { clearTimeout(timer); return; }
|
||||||
|
const t = e.touches[0];
|
||||||
|
start = { x: t.clientX, y: t.clientY };
|
||||||
|
if (!menu.hidden) hideMenu();
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(function () {
|
||||||
|
const s = cur();
|
||||||
|
if (!s) return;
|
||||||
|
selecting = true;
|
||||||
|
selectWord(s.term, cellAt(s.term, s.el, start.x, start.y));
|
||||||
|
showMenu();
|
||||||
|
if (navigator.vibrate) navigator.vibrate(15);
|
||||||
|
}, 450);
|
||||||
|
}, { passive: true, capture: true });
|
||||||
|
box.addEventListener('touchmove', function (e) {
|
||||||
|
const t = e.touches[0], s = cur();
|
||||||
|
if (selecting && s) {
|
||||||
|
e.preventDefault(); // no scrolling while the selection stretches
|
||||||
|
e.stopPropagation();
|
||||||
|
stretch(s.term, cellAt(s.term, s.el, t.clientX, t.clientY));
|
||||||
|
} else if (start && Math.hypot(t.clientX - start.x, t.clientY - start.y) > 8) {
|
||||||
|
clearTimeout(timer); // a swipe: let it scroll
|
||||||
|
}
|
||||||
|
}, { passive: false, capture: true });
|
||||||
|
box.addEventListener('touchend', function (e) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (!selecting) return;
|
||||||
|
selecting = false;
|
||||||
|
e.preventDefault(); // no tap: the keyboard stays as it was
|
||||||
|
e.stopPropagation();
|
||||||
|
showMenu();
|
||||||
|
}, { passive: false, capture: true });
|
||||||
|
box.addEventListener('contextmenu', function (e) { e.preventDefault(); });
|
||||||
|
|
||||||
window.addEventListener('pagehide', function () {
|
const statusEl = document.getElementById('status');
|
||||||
if (sid) navigator.sendBeacon(base + sid + '/close');
|
function flash(t, cls) {
|
||||||
|
statusEl.textContent = t; statusEl.className = cls;
|
||||||
|
setTimeout(function () { const s = cur(); if (s) s.setState(s.state, s.text); }, 1500);
|
||||||
|
}
|
||||||
|
function copyText(text) {
|
||||||
|
if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(text);
|
||||||
|
// Plain http: the old way, through a field that is there for a moment.
|
||||||
|
const ta = document.createElement('textarea');
|
||||||
|
ta.value = text;
|
||||||
|
ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0';
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.select();
|
||||||
|
const ok = document.execCommand('copy');
|
||||||
|
ta.remove();
|
||||||
|
return ok ? Promise.resolve() : Promise.reject(new Error('нет доступа к буферу'));
|
||||||
|
}
|
||||||
|
menu.addEventListener('pointerdown', function (e) { e.preventDefault(); });
|
||||||
|
menu.addEventListener('click', function (e) {
|
||||||
|
const b = e.target.closest('button'), s = cur();
|
||||||
|
if (!b || !s) return;
|
||||||
|
const a = b.dataset.a;
|
||||||
|
if (a === 'copy') {
|
||||||
|
const text = s.term.getSelection();
|
||||||
|
hideMenu();
|
||||||
|
copyText(text).then(function () { flash('скопировано', 'st-ok'); }, function (err) { flash('не скопировалось: ' + err.message, 'st-err'); });
|
||||||
|
} else if (a === 'paste') {
|
||||||
|
hideMenu();
|
||||||
|
if (!navigator.clipboard || !navigator.clipboard.readText) { flash('вставка: браузер не даёт буфер', 'st-err'); return; }
|
||||||
|
navigator.clipboard.readText().then(function (t) { if (t) s.term.paste(t); }, function () { flash('вставка: нет доступа к буферу', 'st-err'); });
|
||||||
|
} else if (a === 'all') {
|
||||||
|
s.term.selectAll();
|
||||||
|
showMenu();
|
||||||
|
} else {
|
||||||
|
hideMenu();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
term.focus();
|
return { withMods: withMods, selectionChanged: selectionChanged };
|
||||||
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