# Ostiary — удалённый запуск скриптов Консольный клиент с текстовым интерфейсом (TUI) на Rust. Загружает иерархическое меню с сервера и позволяет запускать bash-скрипты, скачивать файлы и выполнять HTTP-запросы прямо из терминале. Все диалоги (выбор из списка, подтверждение, ввод текста) реализованы внутри интерфейса — никаких сырых `read -p` в терминале. --- ## Навигация ### Главное меню | Клавиша | Действие | |---|---| | `↑` / `↓` или `k` / `j` | Перемещение по списку | | `Enter` или `l` | Войти в категорию / запустить действие | | `Esc` или `h` | Выйти из категории | | `q` | Выйти из приложения | | `R` | Перезагрузить меню с сервера | ### Popup запущенного скрипта | Клавиша | Действие | |---|---| | `↑` / `↓` или `k` / `j` | Прокрутка лога / навигация по селектору | | `PgUp` / `PgDn` | Прокрутка лога на 10 строк | | `Y` / `N` или `l` / `h` | Ответ на подтверждение (без Enter) | | `Enter` или `l` | Выбрать пункт селектора / подтвердить | | `Esc` или `h` | Прервать скрипт и закрыть popup | > Vim-motions (`j`/`k`/`l`/`h`) отключаются автоматически во время текстового ввода — символы идут в поле ввода как обычно. --- ## Конфигурация При первом запуске клиент запрашивает URL сервера прямо в терминале — до входа в TUI: ``` ╔══════════════════════════════════╗ ║ Ostiary — первый запуск ║ ╚══════════════════════════════════╝ Конфиг будет сохранён в: /home/user/.config/ostiary/config.toml URL сервера меню: https://your-server/api/menu ``` После ввода создаётся `~/.config/ostiary/config.toml`. Его можно редактировать вручную: ```toml server_url = "https://your-server/api/menu" timeout_sec = 10 update_api = "https://git.example.com/api/v1/repos/user/ostiary" [theme] selected_bg = "blue" ``` | Параметр | Описание | |---|---| | `server_url` | URL эндпоинта `GET /api/menu` | | `timeout_sec` | Таймаут HTTP-запросов в секундах | | `update_api` | Базовый URL Gitea API для проверки обновлений (опционально) | --- ## Автообновление Если задан `update_api`, при каждом запуске клиент в фоне проверяет наличие нового релиза в Gitea. Если найдена новая стабильная версия — показывает диалог: ``` Доступно обновление 1.0.0 → 1.1.0 Размер: 4.2 МБ [Y / l] Обновить [N / h / Esc] Пропустить ``` При подтверждении скачивает бинарник, атомарно заменяет текущий исполняемый файл и перезапускает приложение через `execv` (процесс не пересоздаётся, PID остаётся тем же). ### Naming convention ассетов в Gitea Файлы релиза должны называться по шаблону `{name}-{os}-{arch}`: | Файл | Платформа | |---|---| | `ostiary-linux-x86_64` | Linux 64-bit | | `ostiary-linux-aarch64` | Linux ARM64 | | `ostiary-macos-aarch64` | macOS Apple Silicon | | `ostiary-macos-x86_64` | macOS Intel | --- ## Формат меню (JSON) Сервер отдаёт `GET /api/menu` с `Content-Type: application/json`. ### Корневая структура ```json { "version": "1.0", "menu": [ ...пункты... ] } ``` ### Категория Содержит вложенные пункты. Отображается с иконкой `📁`. ```json { "id": "bitrix", "title": "1С Битрикс", "description": "Инструменты администрирования", "children": [ ...пункты меню... ] } ``` ### Действие Отображается с иконкой `▶`. ```json { "id": "db_manage", "title": "Управление БД", "description": "Дамп, импорт, подключение к базе", "action": { "type": "bash", "script": "#!/bin/bash\necho hello", "interaction": "structured", "confirm": false } } ``` #### `confirm` и `confirm_message` Если `confirm: true`, перед запуском появится диалог подтверждения. `confirm_message` переопределяет текст вопроса. ```json { "id": "drop_db", "title": "Удалить базу данных", "action": { "type": "bash", "script": "...", "interaction": "structured", "confirm": true, "confirm_message": "База данных будет удалена без возможности восстановления. Продолжить?" } } ``` --- ## Типы действий ### `bash` — запуск скрипта ```json { "type": "bash", "script": "#!/bin/bash\n...", "interaction": "structured", "confirm": false } ``` | Параметр | Значения | Описание | |---|---|---| | `script` | строка | Полный текст bash-скрипта включая shebang | | `interaction` | `"structured"` / `"terminal"` | Режим взаимодействия | | `confirm` | `true` / `false` | Запросить подтверждение перед запуском | | `confirm_message` | строка | Кастомный текст диалога подтверждения | **`interaction: "terminal"`** — скрипт запускается дочерним процессом без захвата вывода. **`interaction: "structured"`** — весь вывод отображается в прокручиваемом логе с ANSI-цветами, скрипт может запрашивать ввод через протокол команд. ### `download` — скачивание файла ```json { "type": "download", "url": "https://example.com/file.tar.gz", "filename": "backup.tar.gz", "target_dir": "/var/backups", "confirm": false } ``` ### `download_and_run` — скачать и выполнить ```json { "type": "download_and_run", "url": "https://example.com/installer.sh", "run_args": ["--silent", "--prefix=/opt"], "keep_file": false, "confirm": true } ``` ### `http_request` — HTTP-запрос ```json { "type": "http_request", "method": "POST", "url": "https://api.example.com/deploy", "headers": { "Authorization": "Bearer token123" }, "body": "{\"env\": \"production\"}", "confirm": true } ``` --- ## Интерактивные скрипты (structured) При `interaction: "structured"` скрипт общается с клиентом через протокол команд. Команды отправляются в **stderr** (`>&2`), ответы приходят в **stdin**. ### Базовые хелперы ```bash #!/bin/bash # Отправить команду и получить ответ от пользователя ask() { printf 'CMD:%s\n' "$1" >&2 read -r _response printf '%s' "$_response" } # Отправить уведомление (не ждёт ответа, не блокирует скрипт) notify() { printf 'CMD:%s\n' "$1" >&2 } ``` --- ### Ввод текста (`input`) ```json {"type":"input","prompt":"Введите имя пользователя:","default":"admin","secret":false} ``` | Поле | Описание | |---|---| | `prompt` | Заголовок поля ввода | | `default` | Значение по умолчанию (опционально) | | `secret` | Если `true` — символы заменяются на `•` | ```bash username=$(ask '{"type":"input","prompt":"Имя пользователя:","default":"admin"}') password=$(ask '{"type":"input","prompt":"Пароль:","secret":true}') ``` --- ### Выбор из списка (`menu`) Список с навигацией `↑`/`↓`/`j`/`k`, выбор по `Enter`/`l`. Возвращает `id` выбранного пункта. ```json { "type": "menu", "prompt": "Выберите действие:", "options": [ {"id": "dump", "label": "Создать дамп базы"}, {"id": "import", "label": "Импортировать дамп"}, {"id": "connect","label": "Подключиться к БД"} ] } ``` ```bash action=$(ask '{"type":"menu","prompt":"Выберите действие:","options":[ {"id":"dump","label":"Создать дамп"}, {"id":"import","label":"Импортировать"}, {"id":"exit","label":"Отмена"} ]}') case $action in dump) echo "Создаю дамп..." ;; import) echo "Импортирую..." ;; exit) exit 0 ;; esac ``` **Динамический список** (из массива bash): ```bash files=($(ls *.sql 2>/dev/null)) options='[' for i in "${!files[@]}"; do [ $i -gt 0 ] && options+=',' escaped=$(printf '%s' "${files[$i]}" | sed 's/"/\\"/g') options+="{\"id\":\"$i\",\"label\":\"${escaped}\"}" done options+=']' idx=$(ask "{\"type\":\"menu\",\"prompt\":\"Выберите файл:\",\"options\":${options}}") selected="${files[$idx]}" ``` --- ### Подтверждение (`confirm`) Показывает вопрос с кнопками `[Y]`/`[N]`. Клавиши `Y`/`N`/`l`/`h` обрабатываются без Enter. ```json {"type":"confirm","prompt":"Данные будут перезаписаны. Продолжить?"} ``` Ответ: `"y"` или `"n"`. ```bash answer=$(ask '{"type":"confirm","prompt":"Удалить временные файлы?"}') if [[ "$answer" =~ ^[Yy]$ ]]; then rm -rf /tmp/myapp_* fi ``` --- ### Сообщение (`message`) Показывает сообщение с цветом. Скрипт продолжается после нажатия `Enter`. ```json {"type":"message","level":"info","text":"Операция завершена успешно!"} ``` | `level` | Цвет | |---|---| | `"info"` | Зелёный | | `"warn"` | Жёлтый | | `"error"` | Красный | ```bash if ! command -v mysqldump &> /dev/null; then ask '{"type":"message","level":"error","text":"mysqldump не найден."}' > /dev/null exit 1 fi ``` > `> /dev/null` — сбрасывает пустой ответ клиента чтобы он не попал в переменную. --- ### Прогресс (`progress`) Обновляет прогресс-бар не блокируя скрипт (используйте `notify`, не `ask`). ```json {"type":"progress","percent":45,"message":"Копирование файлов..."} ``` ```bash notify '{"type":"progress","percent":0,"message":"Начало..."}' mysqldump -h "$host" -u "$user" "$db" > backup.sql notify '{"type":"progress","percent":70,"message":"Сжимаю..."}' gzip backup.sql notify '{"type":"progress","percent":100,"message":"Готово!"}' ``` --- ### Запуск внешнего приложения (`exec`) Временно **передаёт терминал** внешней программе (mysql, vim, htop и др.). Клиент скрывает TUI, программа работает на полном экране, после завершения TUI восстанавливается. Возвращает код завершения. ```json {"type":"exec","shell":"mysql -h localhost -u root mydb"} ``` ```bash # Интерактивная сессия mysql exit_code=$(ask "{\"type\":\"exec\",\"shell\":\"mysql -h \\\"$host\\\" -u \\\"$user\\\" -p\\\"$pass\\\" \\\"$db\\\"\"}") echo "Сессия завершена (код: $exit_code)" # Редактирование конфига ask '{"type":"exec","shell":"vim /etc/nginx/nginx.conf"}' > /dev/null systemctl reload nginx # Мониторинг ресурсов ask '{"type":"exec","shell":"htop"}' > /dev/null ``` --- ## Полный пример скрипта ```bash #!/bin/bash ask() { printf 'CMD:%s\n' "$1" >&2; read -r _r; printf '%s' "$_r"; } notify() { printf 'CMD:%s\n' "$1" >&2; } host=$(ask '{"type":"input","prompt":"Хост:","default":"localhost"}') db=$(ask '{"type":"input","prompt":"База данных:"}') user=$(ask '{"type":"input","prompt":"Пользователь:","default":"root"}') pass=$(ask '{"type":"input","prompt":"Пароль:","secret":true}') action=$(ask "{\"type\":\"menu\",\"prompt\":\"Выберите действие:\",\"options\":[ {\"id\":\"dump\", \"label\":\"Создать дамп\"}, {\"id\":\"shell\", \"label\":\"Открыть mysql-консоль\"}, {\"id\":\"cancel\",\"label\":\"Отмена\"} ]}") case $action in dump) confirm=$(ask '{"type":"confirm","prompt":"Создать дамп базы данных?"}') [[ ! "$confirm" =~ ^[Yy]$ ]] && exit 0 backup="backup_${db}_$(date +%Y%m%d_%H%M%S).sql" notify '{"type":"progress","percent":10,"message":"Создание дампа..."}' if mysqldump -h "$host" -u "$user" -p"$pass" "$db" > "$backup"; then notify '{"type":"progress","percent":100,"message":"Готово!"}' echo "Дамп: $backup ($(du -h "$backup" | cut -f1))" else ask '{"type":"message","level":"error","text":"Ошибка при создании дампа!"}' > /dev/null exit 1 fi ;; shell) code=$(ask "{\"type\":\"exec\",\"shell\":\"mysql -h \\\"$host\\\" -u \\\"$user\\\" -p\\\"$pass\\\" \\\"$db\\\"\"}") echo "Сессия завершена с кодом $code" ;; cancel) exit 0 ;; esac ``` --- ## Структура сервера ``` server/ ├── index.php # Собирает JSON: menu_structure.json + scripts/ ├── menu_structure.json # Иерархия меню (script_file вместо script) └── scripts/ ├── bitrix_db_manage.sh ├── bitrix_monitoring.sh ├── network_suspicious_connections.sh └── wordpress_db_backup.sh ``` В `menu_structure.json` вместо поля `"script"` используется `"script_file"`: ```json { "id": "db_manage", "title": "Управление БД", "action": { "type": "bash", "script_file": "bitrix_db_manage.sh", "interaction": "structured", "confirm": false } } ``` `index.php` читает файл скрипта при каждом запросе — скрипты редактируются без изменения JSON. ### Добавление нового пункта меню 1. Создать `server/scripts/category_action.sh` 2. Добавить пункт в `menu_structure.json`: ```json { "id": "my_action", "title": "Название в меню", "description": "Описание для нижней панели", "action": { "type": "bash", "script_file": "category_action.sh", "interaction": "structured", "confirm": false } } ``` Соглашение по именованию файлов: `{категория}_{подкатегория}_{действие}.sh` --- ## Сборка Скрипт `release.sh` собирает бинарники для всех платформ сразу: ```bash cd Ostiary ./release.sh # → dist/ostiary-linux-x86_64 # → dist/ostiary-linux-aarch64 # → dist/ostiary-macos-aarch64 ``` **Вручную — Linux:** ```bash cargo build --release # → target/release/ostiary ``` **Вручную — кросс-компиляция macOS → Linux x86_64 (musl, статический):** ```bash rustup target add x86_64-unknown-linux-musl brew install FiloSottile/musl-cross/musl-cross CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER=x86_64-linux-musl-gcc \ cargo build --release --target x86_64-unknown-linux-musl # → target/x86_64-unknown-linux-musl/release/ostiary ``` **Вручную — ARM64 Linux:** ```bash rustup target add aarch64-unknown-linux-musl CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=aarch64-linux-musl-gcc \ cargo build --release --target aarch64-unknown-linux-musl # → target/aarch64-unknown-linux-musl/release/ostiary ``` Бинарник собирается статически (musl) и не требует никаких зависимостей на целевом сервере.