fix: close installer exposure and pin verified payloads
Some checks failed
Bash syntax / syntax (push) Failing after 11s
Bash syntax / syntax (pull_request) Failing after 2s

This commit is contained in:
ochenstarik-ui 2026-10-23 12:33:19 +07:00
parent ef83559f7d
commit 9f06756cef
17 changed files with 1881 additions and 204 deletions

View file

@ -46,3 +46,15 @@ jobs:
- name: Test step 8 AI agent catalog - name: Test step 8 AI agent catalog
run: bash tests/test-step8-agent-catalog.sh run: bash tests/test-step8-agent-catalog.sh
- name: Test firewall and panel security
run: bash tests/test-firewall-panel-security.sh
- name: Test pinned downloads
run: bash tests/test-download-security.sh
- name: Test notifications and backup security
run: bash tests/test-notification-backup-security.sh
- name: Test initial backup policy and upload retry
run: bash tests/test-backup-initial-retry.sh

View file

@ -8,30 +8,37 @@ A modular set of Bash installers for the initial setup, security hardening, admi
## Quick installation ## Quick installation
Copy the complete block into the server terminal. It downloads the repository archive through `codeload.github.com`, stops on errors, and starts the unified installer: Obtain an approved full commit SHA and the archive SHA-256 through a trusted release/review channel. The digest is specific to the selected source: Forgejo and GitHub archives can differ. Do not use a branch name or obtain the expected digest from the downloaded archive itself. Until a reviewed release publishes these values, this procedure requires the operator to supply them.
The complete block verifies the archive before starting the wizard. Forgejo is the default; GitHub is optional:
```bash ```bash
set -e set -e
cd "$HOME" cd "$HOME"
sudo apt-get update sudo apt-get update
sudo apt-get install -y curl ca-certificates tar sudo apt-get install -y curl ca-certificates tar
read -rp 'Reviewed full commit SHA (40 hex): ' revision
read -rp 'Trusted archive SHA-256 (64 hex): ' expected
[[ "$revision" =~ ^[0-9a-f]{40}$ && "$expected" =~ ^[0-9a-f]{64}$ ]]
read -rp 'Source: forgejo or github [forgejo]: ' source
case "${source:-forgejo}" in
forgejo) url="https://git.ochenstarik.ru/ochenstarik/lightweight-server/archive/${revision}.tar.gz" ;;
github) url="https://codeload.github.com/ochenstarik-ui/lightweight-server/tar.gz/${revision}" ;;
*) exit 1 ;;
esac
archive="$(mktemp)" archive="$(mktemp)"
trap 'rm -f "$archive"' EXIT trap 'rm -f "$archive"' EXIT
curl -4 -fL \ curl -fLsS --proto '=https' --proto-redir '=https' --tlsv1.2 \
--retry 5 \ --retry 3 --connect-timeout 30 --max-time 300 "$url" -o "$archive"
--retry-delay 10 \ printf '%s %s\n' "$expected" "$archive" | sha256sum -c -
--connect-timeout 30 \ # Refuse to mix a reviewed archive with files from an earlier installation.
https://codeload.github.com/ochenstarik-ui/lightweight-server/tar.gz/refs/heads/main \ [[ ! -e lightweight-server ]]
-o "$archive" mkdir -m 700 lightweight-server
install -d -m 700 lightweight-server
tar -xzf "$archive" -C lightweight-server --strip-components=1 tar -xzf "$archive" -C lightweight-server --strip-components=1
rm -f "$archive"
trap - EXIT
cd lightweight-server cd lightweight-server
chmod 700 ./*.sh chmod 700 ./*.sh
sudo ./ochenstarik-server-install.sh sudo ./ochenstarik-server-install.sh
``` ```
The first wizard prompt selects the dialog language. English is the default; Russian, Spanish, German, French, Portuguese, Simplified Chinese, Japanese, Arabic, and Hindi are also available. The wizard then opens the main menu with steps 1–8. Select any step, run it, and the wizard returns to the main menu. The first wizard prompt selects the dialog language. English is the default; Russian, Spanish, German, French, Portuguese, Simplified Chinese, Japanese, Arabic, and Hindi are also available. The wizard then opens the main menu with steps 1–8. Select any step, run it, and the wizard returns to the main menu.
Keep the current SSH session open while changing the SSH port. Test the new login in a second terminal before disconnecting. Keep the current SSH session open while changing the SSH port. Test the new login in a second terminal before disconnecting.
@ -48,7 +55,7 @@ The wizard intentionally requires all module scripts to be present locally. Use
| `ochenstarik-server-user-3.sh` | Administrative user, SSH key permissions, SSH migration, sudo, and fail2ban | Run after step 2 | | `ochenstarik-server-user-3.sh` | Administrative user, SSH key permissions, SSH migration, sudo, and fail2ban | Run after step 2 |
| `ochenstarik-server-tg-4.sh` | Telegram notifications for successful SSH logins | Optional | | `ochenstarik-server-tg-4.sh` | Telegram notifications for successful SSH logins | Optional |
| `ochenstarik-server-vpn-5.sh` | System VPN through Xray; repeated runs can connect, disconnect, reconfigure, or show status | Optional | | `ochenstarik-server-vpn-5.sh` | System VPN through Xray; repeated runs can connect, disconnect, reconfigure, or show status | Optional |
| `ochenstarik-server-panel-warp-6.sh` | 3x-ui panel and local Cloudflare WARP proxy | Optional | | `ochenstarik-server-panel-warp-6.sh` | 3x-ui panel and optional local Cloudflare WARP proxy | Optional |
| `ochenstarik-server-backup-7.sh` | Protected initial snapshot and selected daily, weekly, or monthly schedules | Optional | | `ochenstarik-server-backup-7.sh` | Protected initial snapshot and selected daily, weekly, or monthly schedules | Optional |
| `ochenstarik-server-ai-agents-8.sh` | Installs selected AI agents: Hermes, OpenClaw, OpenHands, OpenCode, Aider, AutoGPT, or Pi Coding Agent | Optional | | `ochenstarik-server-ai-agents-8.sh` | Installs selected AI agents: Hermes, OpenClaw, OpenHands, OpenCode, Aider, AutoGPT, or Pi Coding Agent | Optional |
| `ochenstarik-server-uninstall.sh` | Removes project-managed settings so installation can start again | Use carefully | | `ochenstarik-server-uninstall.sh` | Removes project-managed settings so installation can start again | Use carefully |
@ -71,7 +78,7 @@ Replace `SCRIPT_NAME.sh` with the required filename from the table.
4. Create or update the administrative user, install the public SSH key, repair ownership and permissions, and move SSH to the port selected in step 2. 4. Create or update the administrative user, install the public SSH key, repair ownership and permissions, and move SSH to the port selected in step 2.
5. Optionally configure Telegram SSH-login notifications. 5. Optionally configure Telegram SSH-login notifications.
6. Optionally configure the system VPN and subscription port. 6. Optionally configure the system VPN and subscription port.
7. Optionally deploy 3x-ui and Cloudflare WARP with selected panel and subscription ports. 7. Optionally deploy 3x-ui with separate panel/subscription access choices; WARP is a separate opt-in.
8. Optionally create the permanent initial backup and configure rotating schedules. 8. Optionally create the permanent initial backup and configure rotating schedules.
9. Optionally install one or more AI agents for the administrative user. 9. Optionally install one or more AI agents for the administrative user.
@ -88,8 +95,16 @@ sudo ./ochenstarik-server-vpn-5.sh --status
Disconnecting removes the system routing rules but keeps Xray and its configuration so the VPN can be reconnected without entering the subscription again. Disconnecting removes the system routing rules but keeps Xray and its configuration so the VPN can be reconnected without entering the subscription again.
## Pinned VPN and panel installers
Steps 5 and 6 require the bundled `ochenstarik-server-supply-chain.sh`. They verify the installers and executable payloads against committed SHA-256 values: 3x-ui `v3.10.2`, Xray installer commit `05aa47266c94e6fb5ca48cad96afdb4becb5891f`, Xray-core `v26.10.21` (amd64/arm64). Select GitHub or an operator-prepared HTTPS Forgejo mirror. The mirror must contain the files listed below; `ochenstarik-server-supply-chain-pins.txt` summarizes the hashes enforced by the helper; no mirror release is assumed to exist. Unknown installer downloads fail closed. Ordinary OS packages still require access to configured APT repositories.
The mirror layout is flat: `xui-install.sh`, `x-ui-linux-amd64.tar.gz` or `x-ui-linux-arm64.tar.gz`, `x-ui.sh`, `x-ui.service.debian`, `xray-install.sh`, and `Xray-linux-64.zip` or `Xray-linux-arm64-v8a.zip`. Digests are the committed values; the helper creates digest sidecars locally. Step 5 only needs the two Xray files.
## Optional AI agents ## Optional AI agents
Do not install step 8 on VPN servers or Telegram client nodes. Use a separate machine for AI agents.
Step 8 offers a multiple selection of seven popular agents: Hermes Agent, OpenClaw, OpenHands, OpenCode, Aider, AutoGPT, and Pi Coding Agent. Installers are downloaded only from each project's official HTTPS endpoint, checked with `bash -n`, and launched as the selected regular user rather than directly as `root`. Step 8 offers a multiple selection of seven popular agents: Hermes Agent, OpenClaw, OpenHands, OpenCode, Aider, AutoGPT, and Pi Coding Agent. Installers are downloaded only from each project's official HTTPS endpoint, checked with `bash -n`, and launched as the selected regular user rather than directly as `root`.
AutoGPT is a larger Docker-based platform. Its official installer can request `sudo`, install Docker-related dependencies, and start local services. Pi is a lightweight terminal coding agent; after installation, start it with `pi` and use `/login` to connect a supported provider. AutoGPT is a larger Docker-based platform. Its official installer can request `sudo`, install Docker-related dependencies, and start local services. Pi is a lightweight terminal coding agent; after installation, start it with `pi` and use `/login` to connect a supported provider.
@ -102,7 +117,10 @@ The script does not request or store model-provider API keys. After installation
- A repeated step 2 run can change the IP-family mode or add ports without reinstalling everything. - A repeated step 2 run can change the IP-family mode or add ports without reinstalling everything.
- Step 3 reads the SSH port chosen in step 2 and creates an administrator with sudo access. - Step 3 reads the SSH port chosen in step 2 and creates an administrator with sudo access.
- The home directory, `.ssh`, and `authorized_keys` ownership and permissions are checked on every run. - The home directory, `.ssh`, and `authorized_keys` ownership and permissions are checked on every run.
- Do not remove temporary port 22 access until a new SSH session succeeds on the selected port. - Defaults expose only the SSH ports needed during migration and TCP 443. Port 80, subscriptions (2096), WARP (40000), and 63636 are not opened automatically. Additional application rules require an explicit selection; existing managed application rules are listed for an explicit keep/remove decision.
- After you successfully log in on the new SSH port and confirm this in step 3, it removes temporary TCP 22 access from UFW and managed state. Without confirmation it keeps 22; no cleanup runs when SSH remains on 22 or UFW management is disabled.
- The panel defaults to `webListen=127.0.0.1`, checked against real TCP listener addresses. Reach it through an SSH tunnel. Subscriptions independently default to loopback, can be disabled, or can be explicitly published with a random secret path. Existing sufficiently random paths are preserved by default; rotation requires confirmation because it invalidates client URLs. Preserve subscription URLs privately. Only explicitly public services receive UFW rules. WARP defaults to off and remains loopback-only when selected.
- Telegram login notifications run only for PAM `open_session`; detached delivery does not wait for Telegram during login or send a second login notification at session close.
## Backups ## Backups
@ -113,7 +131,9 @@ Step 7 can create:
- weekly backups retained for 4 weeks; - weekly backups retained for 4 weeks;
- monthly backups retained for 12 months. - monthly backups retained for 12 months.
Schedules are selected independently, so any combination can be enabled. Keep an additional copy on another server or storage device; a local backup does not protect against loss of the entire host. Schedules are selected independently. Backups contain server secrets. Local plaintext `.tar.zst` remains the default; opt into age encryption by providing the recipient public key. Encrypted output is streamed into `.tar.zst.age`, with no temporary plaintext archive. Keep the private recovery key separately and test decryption before relying on a backup.
Optional SCP upload sends the completed archive and checksum with batch authentication and strict host-key checking against the operator-supplied known_hosts file. Configure a dedicated destination account and existing SSH key; no passwords or automatic host-key trust. Upload errors are failures. An existing plaintext initial snapshot is preserved: selecting age then stops with an explicit error rather than claiming it is encrypted. Move it to safe storage and resolve the initial-snapshot policy before continuing. Re-running `initial` verifies the existing checksum and retries offsite upload, including after an earlier upload failure. Local retention does not provide remote retention or protect against loss of the host. To restore an encrypted archive, decrypt with `age --decrypt -i RECOVERY_KEY` before passing the stream through `zstd -dc | tar`; never commit recovery keys.
## Multi-server monitoring and management ## Multi-server monitoring and management
@ -131,18 +151,12 @@ The manager is under active early development. Review its security and developme
- Ubuntu Server with systemd and APT; - Ubuntu Server with systemd and APT;
- root access through `sudo`; - root access through `sudo`;
- an active SSH session; - an active SSH session;
- outbound HTTPS access to GitHub or GitHub codeload; - outbound HTTPS access to the selected archive/vendor source and configured APT repositories;
- a valid public SSH key for the administrator setup. - a valid public SSH key for the administrator setup.
## Update ## Update
For a Git clone: Use another reviewed full commit and its source-specific archive checksum with the verified download procedure above, into a fresh directory. Review the changes before running modules again; do not update production installers by pulling a mutable branch.
```bash
cd ~/lightweight-server
git pull
sudo ./ochenstarik-server-install.sh
```
## Reset project-managed configuration ## Reset project-managed configuration

View file

@ -0,0 +1,103 @@
# Lightweight-server: S1–S10, 23.10.2026
Технический результат: **fixed**, S8 — **no_change** (защита уже была в исходном main). Релизная приёмка и merge остаются отдельным gate; автор ветку не вливает. База: `ef83559f7de19fd8649c78b22b05df397650cf46`. Ветка: `codex/lightweight-security-20261023`.
Задание: `agents/codex/inbox/2026-10-23-lightweight-security.md`. Изолированный worktree существующего репозитория, без clone. На хосте и реальных RU/DE/FI/DK серверах установщики и root-команды не запускались. Создана собственная одноразовая KVM Ubuntu 24.04; после проверок остановлена, её диски, seed и временные SSH-ключи удалены.
## Граница исправления
Установщики сами создавали публичные правила и wildcard-слушатели. Исправлена вся цепочка этапов 2/3/6, включая сохранённые управляемые правила и первый запуск upstream-установщика. Частичная правка одного списка портов оставляла бы прежние разрешения и другие этапы, открывающие их снова.
| Пункт | Изменение и подтверждение |
| --- | --- |
| S1 | По умолчанию только текущий/будущий SSH и TCP 443. Нет автоматического 80/2096/40000/63636. Старые управляемые правила требуют явного сохранения; при отказе удаляются реальные UFW-правила IPv4/IPv6 и лишь затем обновляется файл состояния. Проверены реальные правила в VM. |
| S2 | `webListen=127.0.0.1`, адрес проверяется через `ss`. Перед первым upstream start/restart временная обёртка systemctl применяет loopback к панели и отключает подписки; ошибка не допускает старт. Итоговая конфигурация проверяется до снятия защиты и после запуска. |
| S3 | Режимы панели и подписок независимы. Подписки: loopback / выключены / явно публичные со случайным путём. Прежний валидный приватный путь сохраняется; достаточно длинный секретный публичный путь сохраняется по умолчанию. Ротация запрашивает подтверждение и предупреждает о поломке старых URL. Путь в журнал не выводится. |
| S4 | Закреплены 3x-ui v3.10.2, Xray-install `05aa47266c94e6fb5ca48cad96afdb4becb5891f`, Xray-core v26.10.21 и SHA-256 всех используемых исполняемых payload amd64/arm64. Локальный helper проверяет их перед запуском; неизвестные транзитивные загрузки 3x-ui блокируются. GitHub либо HTTPS Forgejo mirror, без fallback. В README загрузка самого проекта по полному проверенному SHA и доверенной сумме, без mutable main. |
| S5 | После подтверждённого оператором нового входа удаляются правила 22/tcp и запись в управляемом списке. Если UFW всё ещё разрешает IPv4/IPv6 22 либо статус не проверен, скрипт не объявляет успех и сохраняет запись. Без подтверждения, при SSH=22 или MANAGE_UFW=no очистка не выполняется. |
| S6 | Только PAM open_session. Отправка через фиксированный `/usr/bin/setsid`, отдельный процесс, stdin/stdout/stderr закрыты. Производственные пути конфигурации и журнала фиксированы, без тестовых environment overrides. Тест: close не отправляет, open запускает одну отправку, возврат <500ms при curl, задержанном на 1s. |
| S7 | WARP — отдельный opt-in, по умолчанию выключен. |
| S8 | Уже присутствовали `--proto '=https' --tlsv1.2`; исходный файл не изменён, регрессия закреплена новым тестом. |
| S9 | Кавычки и обратные слэши экранируются в curl config, управляющие символы отклоняются до вызова curl. Проверены newline-инъекция и большой URL с vertical-tab, без pipefail/grep-q обхода. Секретная ссылка не передаётся в argv curl. |
| S10 | Опциональный age encrypt streaming без временного plaintext-архива; SCP с BatchMode и строгим заранее проверенным known_hosts. Ошибка выгрузки — ошибка операции. Повтор initial проверяет checksum и повторяет выгрузку; несовместимый старый plaintext при выбранном age явно отклоняется без удаления снимка. В VM настоящий age/zstd/tar round-trip совпал с исходным fixture. |
Файлы: этапы `2`, `user-3`, `tg-4`, `vpn-5`, `panel-warp-6`, `backup-7`; новый `ochenstarik-server-supply-chain.sh` и справочный `...pins.txt`; README EN/RU, CI workflow и четыре новых теста. Исходные восемь тестов не изменены.
## Проверки по порядку
1. `find . -type f -name '*.sh' -print0 | xargs -0 -n1 bash -n` — PASS.
2. Новые поведенческие проверки:
- `tests/test-firewall-panel-security.sh`: defaults, миграция старых UFW-правил, IPv6 residual error, sudo без SSH_CONNECTION, независимые режимы/сохранение путей, startup guard, ожидание слушателя, подтверждение удаления 22;
- `tests/test-download-security.sh`: escaping/контрольные символы, cache shim lifecycle и запрет неизвестного installer URL, реальные вызовы helper из этапов 5/6, отсутствие upstream main, отсутствие fallback при ошибке mirror;
- `tests/test-notification-backup-security.sh`: PAM open/close, detached delivery, строгие SSH options и ошибка offsite;
- `tests/test-backup-initial-retry.sh`: реальный early-return путь initial, ошибка SCP → повтор, неизменность snapshot/checksum, age policy с прежним plaintext.
3. `for test in tests/*.sh; do bash "$test"; done` — **12/12 PASS**; `git diff --check` — PASS; diff исходных `tests/` пуст.
4. Одноразовая Ubuntu 24.04 amd64, настоящий systemd/UFW/3x-ui: этап 2 (полная установка), миграция старых правил, этап 6 fresh install и повтор с публичными подписками — exit 0.
5. Тот же VM: helper `prepare-xray` → `install-xray` — exit 0, реальный Xray **26.10.21**; age-backup round-trip — PASS.
## Настоящие UFW и TCP-слушатели
Ubuntu image SHA-256: `04e8f2461a16a44e78a370284471f29616cdd9baf1893df25807b71f985b6c94`, проверен по официальному SHA256SUMS cloud-images. SSH в тесте оставлен на 22; WARP выключен.
После полного этапа 2 и после удаления прежних 80/2096/40000/63636:
```text
ufw status verbose
Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), disabled (routed)
New profiles: skip
22/tcp ALLOW IN Anywhere
443/tcp ALLOW IN Anywhere
22/tcp (v6) ALLOW IN Anywhere (v6)
443/tcp (v6) ALLOW IN Anywhere (v6)
```
После fresh этапа 6 с приватной панелью и приватными подписками UFW остаётся тем же. Полный `ss -ltn` (сокращены только столбцы очередей):
```text
Local Address:Port Peer Address:Port
127.0.0.1:2096 0.0.0.0:*
127.0.0.1:2053 0.0.0.0:*
0.0.0.0:22 0.0.0.0:*
127.0.0.54:53 0.0.0.0:*
127.0.0.1:62789 0.0.0.0:*
127.0.0.53%lo:53 0.0.0.0:*
127.0.0.1:11111 0.0.0.0:*
[::]:22 [::]:*
```
DNS/внутренние Xray-порты остаются локальными. Разрешение UFW 443 не означает, что скрипт создаёт на 443 отдельный HTTPS-сервис. Во время sampled fresh bootstrap наблюдались только `127.0.0.1:49587` (временный upstream порт), `127.0.0.1:2053`, `127.0.0.1:2096`; sampling дополняет pre-start enforcement, не доказывает отсутствие любого мгновенного слушателя сам по себе.
При явном публичном доступе к подпискам UFW дополнительно содержит:
```text
2096/tcp ALLOW IN Anywhere
2096/tcp (v6) ALLOW IN Anywhere (v6)
```
`ss -ltn`: панель **127.0.0.1:2053**, подписки **\*:2096**. DB: `webListen=127.0.0.1`, `subListen=''`, `subEnable=true`. Секретный путь не выгружался в журнал.
## Два аудитора и собственная проверка
Дешёвые исполнители: `lightweight_firewall_panel`, `lightweight_downloads_notifications_backup`. До патча независимый boundary investigator; после кандидата два свежих read-only Codex auditor: `lightweight_security_audit_one`, `lightweight_security_audit_two`. Один цикл аудита, без самооценки авторского кода как релизного approval.
Подтверждены и исправлены найденные аудиторами: сохранённые UFW entries, игнорирование ошибки удаления 22, IPv6 статус, потеря/ротация прежнего subscription path, initial/offsite retry и несовместимая age policy, неточное описание pin manifest. Root дополнительно воспроизвёл реальные ошибки: sudo очищает SSH_CONNECTION, повторный mkdir shim и readiness race после systemctl start. После исправления повторил focused, все repository checks и настоящий VM-прогон. Неподтверждённое замечание об обязательной root escalation через PAM аудитор отозвал; тестовые env overrides всё равно удалены из производства.
## Evidence и пределы
Сырые обезличенные результаты сохранены в managed Codex Security artifact storage, target `lightweight-server`, bundle `artifacts-827d7430ed6521a319c17103be0c53cc1d742842b3e097b3a5f63693cead15d9`, каталог `artifacts/lightweight-20261023/`:
| Файл | SHA-256 |
| --- | --- |
| final-checks.log | 57be6a409fbce5488611bbc853eaecaacecdde8fa7c40656b87fb2ec925970f2 |
| ubuntu24-step2.log | 6e37718bf82ef1bf061449dee8782d79855946dca2ba5776e6bc9bada3dbec37 |
| ubuntu24-migration.log | 0257a0c97a8dd592c3492d886ad6fb42af2fedff7139eb70ea16387a8b2e2bab |
| ubuntu24-private.log | 4de0099004bcb79822b5a915d1e5914ea3b447e8b41e67471c730f75f39553c8 |
| ubuntu24-public.log | f0d75dd045e89ca9f8066542e4806b20c10724165e07f56f659b35c83728274f |
| ubuntu24-age.log | 737740b39ceba68214737f21283fea1d37a5f448c5bb3addcb6eef1d39fbcc09 |
| ubuntu24-xray.log | 270af1adae421035c3d97d4b0779c2d2f922e72208988036a3e534cff27c2a14 |
Полная VPN-маршрутизация реального сервера, WARP opt-in, arm64 execution и реальный SCP на чужой хост не запускались; вне требуемого Ubuntu amd64 прогона и разрешений. Mirror проверен на fail-closed отказ; готовый публичный mirror релиз не опубликован, нужны файлы с указанными суммами. Блок README требует реально одобренные SHA+checksum и не выдумывает готовый release.
По последнему правилу владельца новый PR ждёт Grok на точном SHA либо Claude по прямому запросу владельца. Два технических Codex-аудита этот release gate не заменяют. CI удалённой ветки проверяется отдельно после публикации; этот отчёт фиксирует локальные/VM результаты.

View file

@ -0,0 +1,36 @@
# Codex — lightweight-server: исправления по аудиту безопасности (23.10)
От: Claude, передаёт владелец. Репозиторий `git.ochenstarik.ru/ochenstarik/lightweight-server`, main `ef83559`.
Если у тебя уже есть задание владельца по этому репо — это дополнение к нему: не дублируй, сведи в одну ветку или согласуй порядок.
## Правила
- worktree от `/srv/projects/Agent projects/lightweight-server`, без клонов; одна задача — одна ветка — один PR; в main сам не вливаешь;
- существующие тесты в `tests/` не менять, проверки не ослаблять; новые тесты — на поведение из таблицы;
- **ничего не запускать на реальных серверах** (RU/DE/FI/DK) и на этой машине от root: проверки — `bash -n`, тесты, при необходимости
одноразовый контейнер/VM Ubuntu 24.04 с удалением после прогона;
- секретов в Git и логах нет; README.md и `docs/readme/README.ru.md` обновить под изменённое поведение (остальные переводы — по возможности);
- режим: дешёвые модели → два аудитора → сам проверяешь. Отчёт — `agents/codex/done/` в этом репо.
## Зачем
Серверы: RU — вход семейного VPN (3x-ui, панель только через SSH-туннель, подписки — семье), DE/FI/DK — выходы VPN и одновременно
узлы Telegram-клиента. План: `kagent/agents/claude/done/2026-10-23-vps-plan.md`. Владелец ставит серверы этими скриптами —
исправления нужны до российского сервера, в первую очередь S1–S3.
| # | Находка | Где | Серьёзность | Исправление |
|---|---|---|---|---|
| S1 | Шаг 2 по умолчанию открывает всему миру 22, 80, **2096**, 443, 40000, 63636. 2096 — порт подписок 3x-ui по умолчанию, поэтому «приватный режим» шага 6 не закрывает подписки; 40000/63636 открыты без сервиса | `server-2.sh:138`, `user-3.sh:491` | высокая | по умолчанию только SSH-порт и 443; остальное — явным выбором с объяснением |
| S2 | Приватный режим панели — только правило UFW, панель слушает `0.0.0.0`. Docker и чужие nftables-таблицы (узел Telegram) обходят/перекрывают UFW | `panel-warp-6.sh` `configure_xui_ports` | высокая | в приватном режиме `webListen=127.0.0.1`; проверка `ss -ltn`, что панель не на внешнем адресе |
| S3 | Панель и подписки — один переключатель | `panel-warp-6.sh:115` | средняя | раздельно: панель (по умолчанию закрыта, 127.0.0.1) и подписки (по выбору публично, случайный путь) |
| S4 | 3x-ui и Xray ставятся установщиками с `main` апстрима, без версии и контрольной суммы; `bash -n` — только синтаксис. Быстрая установка самого проекта — с GitHub `main` | `panel-warp-6.sh:4`, `vpn-5.sh:404`, README | средняя | фиксированный тег релиза + sha256; источник по выбору — Forgejo `git.ochenstarik.ru` (с RU-серверов GitHub бывает недоступен) |
| S5 | Порт 22 остаётся открытым после переноса SSH — только напечатанная подсказка | `user-3.sh:537` | средняя | после успешного входа на новый порт (подтверждение оператора) удалить правило 22 автоматически |
| S6 | Уведомление о входе: `pam_exec` в `session` срабатывает и при открытии, и при закрытии сессии (двойное «успешный вход»); отправка синхронная, до 10 с задержки входа, если api.telegram.org недоступен (RU) | `tg-4.sh` hook | низкая | выходить, если `PAM_TYPE != open_session`; отправлять в фоне (`setsid … &`) |
| S7 | WARP в шаге 6 обязателен; на RU не нужен и может не подняться — скрипт падает после установки 3x-ui | `panel-warp-6.sh` | низкая | WARP — отдельный вопрос/флаг |
| S8 | Загрузка установщиков ИИ-агентов без `--proto '=https'` (редирект может уйти на http) | `ai-agents-8.sh:158` | низкая | добавить `--proto '=https' --tlsv1.2` |
| S9 | URL подписки вставляется в конфиг curl через `printf 'url = "%s"'`: кавычка в ссылке ломает конфиг (ввод оператора) | `vpn-5.sh` | низкая | экранировать `\` и `"` или передавать URL через `--url` из файла |
| S10 | Бэкап — полный снимок ФС с секретами (БД 3x-ui, ключи хоста, токен бота), без шифрования, только локально | `backup-7.sh` | инфо | опция шифрования (age/gpg) и выгрузки на другой хост |
Рекомендация к эксплуатации (не код): на серверах VPN/узлов Telegram **не ставить шаг 8 (ИИ-агенты)** — это лишний код с правами пользователя-админа.
**Приёмка:** новые тесты в `tests/` на S1–S3, S5, S6 (рендер правил UFW/настроек 3x-ui, проверка PAM_TYPE); существующие тесты не менять; `bash -n` и все тесты PASS.
Дополнительно к приёмке: прогон шагов 2 и 6 в одноразовом Ubuntu 24.04 (контейнер с systemd или VM) — вывод `ufw status verbose`
и `ss -ltn` в отчёте: открыты только SSH-порт и 443 (+ подписки, если выбраны), панель слушает только `127.0.0.1`.

View file

@ -8,30 +8,37 @@
## Быстрая установка ## Быстрая установка
Скопируйте весь блок в терминал сервера. Он загружает архив через `codeload.github.com`, проверяет ошибки на каждом шаге и запускает единый пошаговый мастер: Получите полный SHA одобренного коммита и SHA-256 архива из доверенного канала релиза или ревью. Контрольная сумма зависит от источника: архивы Forgejo и GitHub могут различаться. Не используйте имя ветки и не вычисляйте «ожидаемую» сумму из самого скачанного архива. Пока проверенный релиз не опубликовал эти значения, оператор должен указать их сам.
Блок проверяет архив до запуска мастера. По умолчанию выбран Forgejo; GitHub можно выбрать отдельно:
```bash ```bash
set -e set -e
cd "$HOME" cd "$HOME"
sudo apt-get update sudo apt-get update
sudo apt-get install -y curl ca-certificates tar sudo apt-get install -y curl ca-certificates tar
read -rp 'Reviewed full commit SHA (40 hex): ' revision
read -rp 'Trusted archive SHA-256 (64 hex): ' expected
[[ "$revision" =~ ^[0-9a-f]{40}$ && "$expected" =~ ^[0-9a-f]{64}$ ]]
read -rp 'Source: forgejo or github [forgejo]: ' source
case "${source:-forgejo}" in
forgejo) url="https://git.ochenstarik.ru/ochenstarik/lightweight-server/archive/${revision}.tar.gz" ;;
github) url="https://codeload.github.com/ochenstarik-ui/lightweight-server/tar.gz/${revision}" ;;
*) exit 1 ;;
esac
archive="$(mktemp)" archive="$(mktemp)"
trap 'rm -f "$archive"' EXIT trap 'rm -f "$archive"' EXIT
curl -4 -fL \ curl -fLsS --proto '=https' --proto-redir '=https' --tlsv1.2 \
--retry 5 \ --retry 3 --connect-timeout 30 --max-time 300 "$url" -o "$archive"
--retry-delay 10 \ printf '%s %s\n' "$expected" "$archive" | sha256sum -c -
--connect-timeout 30 \ # Refuse to mix a reviewed archive with files from an earlier installation.
https://codeload.github.com/ochenstarik-ui/lightweight-server/tar.gz/refs/heads/main \ [[ ! -e lightweight-server ]]
-o "$archive" mkdir -m 700 lightweight-server
install -d -m 700 lightweight-server
tar -xzf "$archive" -C lightweight-server --strip-components=1 tar -xzf "$archive" -C lightweight-server --strip-components=1
rm -f "$archive"
trap - EXIT
cd lightweight-server cd lightweight-server
chmod 700 ./*.sh chmod 700 ./*.sh
sudo ./ochenstarik-server-install.sh sudo ./ochenstarik-server-install.sh
``` ```
Первым запросом мастер предлагает выбрать язык диалога и только затем открывает главное меню со всеми этапами 1–8. Выберите нужный этап, установите его, и после завершения мастер вернётся обратно в главное меню. В первом этапе пустой ввод устанавливает все рекомендуемые наборы программ. Во время изменения SSH-порта не закрывайте текущую сессию — сначала проверьте новый вход во втором терминале. Первым запросом мастер предлагает выбрать язык диалога и только затем открывает главное меню со всеми этапами 1–8. Выберите нужный этап, установите его, и после завершения мастер вернётся обратно в главное меню. В первом этапе пустой ввод устанавливает все рекомендуемые наборы программ. Во время изменения SSH-порта не закрывайте текущую сессию — сначала проверьте новый вход во втором терминале.
Мастер намеренно требует, чтобы все скрипты этапов уже лежали рядом локально. Используйте полный архив или `git clone`; одиночный файл мастера больше не подкачивает недостающие этапы из изменяемой ветки `main`. Мастер намеренно требует, чтобы все скрипты этапов уже лежали рядом локально. Используйте полный архив или `git clone`; одиночный файл мастера больше не подкачивает недостающие этапы из изменяемой ветки `main`.
@ -62,39 +69,7 @@ sudo ./ochenstarik-server-install.sh
## Получение проекта ## Получение проекта
Рекомендуемый способ — загрузка архива через `codeload.github.com`. Он подходит в том числе для серверов, на которых соединение с основным доменом `github.com` завершается ошибкой `SSL connection timeout`: Используйте проверенную загрузку полного архива из раздела «Быстрая установка». Все модули и `ochenstarik-server-supply-chain.sh` должны оставаться рядом. Для обновления нужен новый проверенный полный SHA и соответствующая источнику сумма; распаковывайте в новый каталог. Не обновляйте установщики на рабочих серверах через изменяемую ветку.
```bash
set -e
cd "$HOME"
sudo apt-get update
sudo apt-get install -y curl ca-certificates tar
curl -4 -fL \
--retry 5 \
--retry-delay 10 \
--connect-timeout 30 \
https://codeload.github.com/ochenstarik-ui/lightweight-server/tar.gz/refs/heads/main \
-o lightweight-server.tar.gz
mkdir lightweight-server
tar -xzf lightweight-server.tar.gz -C lightweight-server --strip-components=1
rm lightweight-server.tar.gz
cd lightweight-server
chmod 700 ./*.sh
```
Если `github.com` доступен с сервера, проект также можно получить через Git:
```bash
set -e
cd "$HOME"
sudo apt-get update
sudo apt-get install -y git ca-certificates
git -c http.version=HTTP/1.1 clone --depth 1 https://github.com/ochenstarik-ui/lightweight-server.git
cd lightweight-server
chmod 700 ./*.sh
```
Команда `set -e` останавливает последовательность при ошибке загрузки, поэтому команды `cd` и `chmod` не будут выполняться для отсутствующей папки.
Перед запуском любого выбранного файла можно проверить его синтаксис: Перед запуском любого выбранного файла можно проверить его синтаксис:
@ -129,7 +104,6 @@ bash -n ./ИМЯ-СКРИПТА.sh
Затем скрипт создаёт `/swapfile` размером 2 ГБ, добавляет его в `/etc/fstab` и устанавливает `vm.swappiness=20`. Повторный запуск предусмотрен. Затем скрипт создаёт `/swapfile` размером 2 ГБ, добавляет его в `/etc/fstab` и устанавливает `vm.swappiness=20`. Повторный запуск предусмотрен.
```bash ```bash
curl -fLO https://raw.githubusercontent.com/ochenstarik-ui/lightweight-server/main/ochenstarik-server-1.sh
chmod 700 ochenstarik-server-1.sh chmod 700 ochenstarik-server-1.sh
bash -n ochenstarik-server-1.sh bash -n ochenstarik-server-1.sh
sudo ./ochenstarik-server-1.sh sudo ./ochenstarik-server-1.sh
@ -158,15 +132,9 @@ sysctl vm.swappiness
В режиме «только IPv6» системный IPv4 остаётся доступен для исходящих соединений и работы менеджера пакетов, но входящие управляемые порты по IPv4 закрываются UFW. Полное переносимое отключение IPv4 в Linux без привязки к конкретной сетевой конфигурации не выполняется. В режиме «только IPv6» системный IPv4 остаётся доступен для исходящих соединений и работы менеджера пакетов, но входящие управляемые порты по IPv4 закрываются UFW. Полное переносимое отключение IPv4 в Linux без привязки к конкретной сетевой конфигурации не выполняется.
Открываются TCP-порты: По умолчанию открываются только необходимые SSH-порты на время переноса и `443/tcp`. Порты `80`, `2096`, `40000` и `63636` автоматически не публикуются. Дополнительные порты требуют явного выбора с учётом назначения сервиса. При повторном запуске старые управляемые порты приложений выводятся отдельно: оператор явно выбирает, сохранить их или убрать.
- `22` — временный SSH-порт;
- выбранный SSH-порт — будущий порт SSH;
- `80`, `443` — HTTP и HTTPS;
- `2096`, `40000`, `63636` — порты приложений.
```bash ```bash
curl -fLO https://raw.githubusercontent.com/ochenstarik-ui/lightweight-server/main/ochenstarik-server-2.sh
chmod 700 ochenstarik-server-2.sh chmod 700 ochenstarik-server-2.sh
bash -n ochenstarik-server-2.sh bash -n ochenstarik-server-2.sh
sudo ./ochenstarik-server-2.sh sudo ./ochenstarik-server-2.sh
@ -208,7 +176,6 @@ timedatectl
```bash ```bash
sudo apt-get update sudo apt-get update
sudo apt-get install -y sudo ufw fail2ban openssl openssh-server sudo apt-get install -y sudo ufw fail2ban openssl openssh-server
curl -fLO https://raw.githubusercontent.com/ochenstarik-ui/lightweight-server/main/ochenstarik-server-user-3.sh
chmod 700 ochenstarik-server-user-3.sh chmod 700 ochenstarik-server-user-3.sh
bash -n ochenstarik-server-user-3.sh bash -n ochenstarik-server-user-3.sh
sudo ./ochenstarik-server-user-3.sh sudo ./ochenstarik-server-user-3.sh
@ -220,11 +187,7 @@ sudo ./ochenstarik-server-user-3.sh
ssh -p ВЫБРАННЫЙ_ПОРТ ИМЯ_ПОЛЬЗОВАТЕЛЯ@IP_СЕРВЕРА ssh -p ВЫБРАННЫЙ_ПОРТ ИМЯ_ПОЛЬЗОВАТЕЛЯ@IP_СЕРВЕРА
``` ```
Только после успешной проверки можно удалить временное правило порта 22: После успешного входа на новый порт подтвердите это в запросе этапа 3: скрипт сам удалит временное правило `22/tcp` для IPv4/IPv6 и запись в управляемом списке. Без подтверждения порт сохраняется. Если SSH остаётся на 22 или управление UFW отключено, очистка не выполняется.
```bash
sudo ufw delete allow 22/tcp
```
Чтобы позднее открыть дополнительные порты, повторно запустите скрипт, выберите пункт `2` и введите правила через пробел или запятую: Чтобы позднее открыть дополнительные порты, повторно запустите скрипт, выберите пункт `2` и введите правила через пробел или запятую:
@ -238,14 +201,13 @@ sudo ufw delete allow 22/tcp
Файл: `ochenstarik-server-tg-4.sh`. Файл: `ochenstarik-server-tg-4.sh`.
Создайте бота через `@BotFather`, отправьте ему сообщение и узнайте ID чата. Скрипт запросит токен и ID интерактивно, отправит тестовое сообщение и подключит уведомления через PAM. Создайте бота через `@BotFather`, отправьте ему сообщение и узнайте ID чата. Скрипт запросит токен и ID интерактивно, отправит тестовое сообщение и подключит уведомления через PAM. Hook обрабатывает только `PAM_TYPE=open_session`; закрытие сессии не отправляет второе «успешный вход». Отправка запускается через отсоединённый процесс с закрытым stdin/stdout/stderr и не задерживает SSH-вход при недоступности Telegram.
Для отдельного запуска: Для отдельного запуска:
```bash ```bash
sudo apt-get update sudo apt-get update
sudo apt-get install -y curl openssh-server logrotate sudo apt-get install -y curl openssh-server logrotate
curl -fLO https://raw.githubusercontent.com/ochenstarik-ui/lightweight-server/main/ochenstarik-server-tg-4.sh
chmod 700 ochenstarik-server-tg-4.sh chmod 700 ochenstarik-server-tg-4.sh
bash -n ochenstarik-server-tg-4.sh bash -n ochenstarik-server-tg-4.sh
sudo ./ochenstarik-server-tg-4.sh sudo ./ochenstarik-server-tg-4.sh
@ -268,7 +230,6 @@ sudo tail -f /var/log/ochenstarik-ssh-login-telegram.log
При повторном запуске появляется меню: подключить VPN с сохранёнными настройками, отключить его без удаления конфигурации, заменить подписку либо показать состояние. При повторном запуске появляется меню: подключить VPN с сохранёнными настройками, отключить его без удаления конфигурации, заменить подписку либо показать состояние.
```bash ```bash
curl -fLO https://raw.githubusercontent.com/ochenstarik-ui/lightweight-server/main/ochenstarik-server-vpn-5.sh
chmod 700 ochenstarik-server-vpn-5.sh chmod 700 ochenstarik-server-vpn-5.sh
bash -n ochenstarik-server-vpn-5.sh bash -n ochenstarik-server-vpn-5.sh
sudo ./ochenstarik-server-vpn-5.sh sudo ./ochenstarik-server-vpn-5.sh
@ -298,26 +259,22 @@ sudo ./ochenstarik-server-vpn-5.sh --status
Файл: `ochenstarik-server-panel-warp-6.sh`. Файл: `ochenstarik-server-panel-warp-6.sh`.
Скрипт предлагает выбрать три разных порта: Панель по умолчанию приватная: `webListen=127.0.0.1`; адрес реального слушателя проверяется через `ss -ltn`. Доступ — через SSH-туннель. Порт панели по умолчанию `2053`.
- порт веб-панели 3x-ui, по умолчанию `2053`; Подписки на отдельном порту (по умолчанию `2096`) выбираются независимо: приватные на loopback, полностью выключенные либо явно публичные с новым случайным секретным путём. При повторном запуске прежний достаточно случайный путь сохраняется по умолчанию; ротация требует подтверждения, поскольку старые URL перестанут работать. Храните URL подписок как секрет. Публичная панель — отдельный явный выбор. В UFW публикуются только выбранные публичные сервисы и `443/tcp`; порт 80 автоматически не открывается.
- порт подписок 3x-ui, по умолчанию `2096`;
- локальный SOCKS5/HTTP proxy-порт WARP, по умолчанию `40000`.
3x-ui устанавливается официальным интерактивным установщиком. После установки выбранные порты записываются в стандартную SQLite-базу с предварительной резервной копией. Скрипт включает сервис подписок, запускает `x-ui` и проверяет оба TCP-слушателя. 3x-ui `v3.10.2`, установщик Xray на коммите `05aa47266c94e6fb5ca48cad96afdb4becb5891f` и Xray-core `v26.10.21` закреплены вместе с SHA-256 установщиков и исполняемых payload для amd64/arm64. Этапы 5 и 6 требуют комплектный `ochenstarik-server-supply-chain.sh`. Источник выбирается отдельно: GitHub либо подготовленное оператором HTTPS-зеркало Forgejo. Зеркало должно содержать перечисленные ниже файлы; `ochenstarik-server-supply-chain-pins.txt` — справочный список сумм, проверяемых helper; существование готового зеркального релиза не предполагается. Неизвестные загрузки установщика блокируются. Системные пакеты по-прежнему требуют доступных APT-репозиториев.
Cloudflare WARP устанавливается из официального APT-репозитория, переводится в Local proxy mode через MASQUE и проверяется запросом к Cloudflare: ответ должен содержать `warp=on`. Файлы зеркала лежат в одном каталоге: `xui-install.sh`, `x-ui-linux-amd64.tar.gz` либо `x-ui-linux-arm64.tar.gz`, `x-ui.sh`, `x-ui.service.debian`, `xray-install.sh`, `Xray-linux-64.zip` либо `Xray-linux-arm64-v8a.zip`. Суммы закреплены в коде; файлы контрольных сумм helper создаёт локально. Для этапа 5 нужны только два файла Xray.
WARP — отдельный необязательный выбор, по умолчанию выключен. Если выбран, устанавливается из официального APT-репозитория, работает в Local proxy mode через MASQUE и проверяется по `warp=on`. Его proxy слушает только `127.0.0.1:40000` (либо выбранный локальный порт) и не публикуется через UFW.
```bash ```bash
curl -fLO https://raw.githubusercontent.com/ochenstarik-ui/lightweight-server/main/ochenstarik-server-panel-warp-6.sh
chmod 700 ochenstarik-server-panel-warp-6.sh
bash -n ochenstarik-server-panel-warp-6.sh bash -n ochenstarik-server-panel-warp-6.sh
sudo ./ochenstarik-server-panel-warp-6.sh sudo ./ochenstarik-server-panel-warp-6.sh
``` ```
В UFW открываются и проверяются `80/tcp`, `443/tcp`, выбранный порт панели и выбранный порт подписок. WARP слушает только локально на `127.0.0.1:40000` либо другом выбранном порту. Этот порт намеренно не публикуется через UFW, поскольку локальный proxy не требует аутентификации. Публичные порты открываются только для семейств адресов, выбранных на этапе 2.
Публичные порты 3x-ui открываются только для семейств адресов, выбранных на этапе 2. В режиме `ipv4` скрипт не создаёт правил IPv6.
Поддерживаются официально доступные пакеты WARP для Ubuntu `jammy`, `noble`, `resolute` и Debian `bookworm`, `trixie` на `amd64` и `arm64`. Поддерживаются официально доступные пакеты WARP для Ubuntu `jammy`, `noble`, `resolute` и Debian `bookworm`, `trixie` на `amd64` и `arm64`.
@ -337,7 +294,6 @@ sudo ./ochenstarik-server-panel-warp-6.sh
- только первичный снимок без автоматического расписания. - только первичный снимок без автоматического расписания.
```bash ```bash
curl -fLO https://raw.githubusercontent.com/ochenstarik-ui/lightweight-server/main/ochenstarik-server-backup-7.sh
chmod 700 ochenstarik-server-backup-7.sh chmod 700 ochenstarik-server-backup-7.sh
bash -n ochenstarik-server-backup-7.sh bash -n ochenstarik-server-backup-7.sh
sudo ./ochenstarik-server-backup-7.sh sudo ./ochenstarik-server-backup-7.sh
@ -367,7 +323,9 @@ mkdir -p /restore
zstd -dc /ПУТЬ/К/АРХИВУ.tar.zst | tar --acls --xattrs --numeric-owner -xpf - -C /restore zstd -dc /ПУТЬ/К/АРХИВУ.tar.zst | tar --acls --xattrs --numeric-owner -xpf - -C /restore
``` ```
Это локальная резервная копия, а не защита от отказа диска или потери сервера. Регулярно переносите готовые архивы и `.sha256` на отдельный сервер или накопитель. Снимок создаётся с работающей системы; для активно изменяющихся баз данных дополнительно используйте штатный экспорт или согласованный снимок самой базы. Архив содержит секреты сервера. По умолчанию сохраняется локальный незашифрованный `.tar.zst`. Шифрование age включается отдельно по публичному ключу получателя: поток сразу записывается в `.tar.zst.age`, временного незашифрованного архива нет. Приватный ключ восстановления храните отдельно; заранее проверьте расшифровку через `age --decrypt -i RECOVERY_KEY` перед `zstd -dc | tar`.
Можно отдельно включить SCP-выгрузку готового архива и `.sha256` на другой хост. Нужны выделенная учётная запись, существующий SSH-ключ и заранее проверенный known_hosts. Используются BatchMode и строгая проверка ключа хоста; ошибки передачи считаются ошибкой бэкапа. Существующий незашифрованный первичный снимок сохраняется: при выборе age скрипт явно остановится, а не объявит его зашифрованным. Перенесите прежний снимок в безопасное хранилище и определите политику первичного снимка перед продолжением. Повторный запуск `initial` проверяет контрольную сумму и повторяет выгрузку, в том числе после прежней ошибки SCP. Локальная ротация не настраивает удалённую ротацию. Локальная копия не защищает от потери сервера. Снимок создаётся с работающей системы; для активно изменяющихся баз данных дополнительно используйте штатный экспорт или согласованный снимок самой базы.
## Полная установка ## Полная установка
@ -399,6 +357,8 @@ sudo ./ochenstarik-server-ai-agents-8.sh
Файл: `ochenstarik-server-ai-agents-8.sh`. Файл: `ochenstarik-server-ai-agents-8.sh`.
На серверах VPN и узлах Telegram-клиента не устанавливайте этап 8. ИИ-агенты должны работать на отдельной машине.
Скрипт предлагает множественный выбор из Hermes Agent, OpenClaw, OpenHands, OpenCode, Aider, AutoGPT и Pi Coding Agent. Установщики запускаются от выбранного обычного пользователя, а не напрямую от `root`. Каждый официальный установщик сначала загружается по HTTPS во временный файл и проходит проверку `bash -n`. Скрипт предлагает множественный выбор из Hermes Agent, OpenClaw, OpenHands, OpenCode, Aider, AutoGPT и Pi Coding Agent. Установщики запускаются от выбранного обычного пользователя, а не напрямую от `root`. Каждый официальный установщик сначала загружается по HTTPS во временный файл и проходит проверку `bash -n`.
AutoGPT — более тяжёлая Docker-платформа: её официальный установщик может запросить `sudo`, установить зависимости Docker и запустить локальные службы. Pi — лёгкий терминальный агент; после установки его можно запустить командой `pi`, а затем выполнить `/login` для подключения провайдера. AutoGPT — более тяжёлая Docker-платформа: её официальный установщик может запросить `sudo`, установить зависимости Docker и запустить локальные службы. Pi — лёгкий терминальный агент; после установки его можно запустить командой `pi`, а затем выполнить `/login` для подключения провайдера.
@ -406,7 +366,6 @@ AutoGPT — более тяжёлая Docker-платформа: её офици
API-ключи скрипт не запрашивает и не сохраняет. После установки нужно войти под выбранным пользователем и выполнить показанную команду первоначальной настройки. AI-агенты могут запускать команды, изменять файлы, использовать сеть и устанавливать плагины, поэтому перед подключением рабочих данных необходимо проверить разрешения, песочницу и доступ к секретам. API-ключи скрипт не запрашивает и не сохраняет. После установки нужно войти под выбранным пользователем и выполнить показанную команду первоначальной настройки. AI-агенты могут запускать команды, изменять файлы, использовать сеть и устанавливать плагины, поэтому перед подключением рабочих данных необходимо проверить разрешения, песочницу и доступ к секретам.
```bash ```bash
curl -fLO https://raw.githubusercontent.com/ochenstarik-ui/lightweight-server/main/ochenstarik-server-ai-agents-8.sh
chmod 700 ochenstarik-server-ai-agents-8.sh chmod 700 ochenstarik-server-ai-agents-8.sh
bash -n ochenstarik-server-ai-agents-8.sh bash -n ochenstarik-server-ai-agents-8.sh
sudo ./ochenstarik-server-ai-agents-8.sh sudo ./ochenstarik-server-ai-agents-8.sh
@ -423,7 +382,6 @@ VPN, 3x-ui, WARP, Telegram-уведомления, автоматические
Удаление администратора и самих архивов резервных копий требует отдельных подтверждений. Обязательные системные пакеты второго этапа и обновления Ubuntu сохраняются, чтобы не потерять доступ к серверу. Языковые пакеты и дополнительные программы удаляются только если первый скрипт зафиксировал, что до его запуска их не было. Удаление администратора и самих архивов резервных копий требует отдельных подтверждений. Обязательные системные пакеты второго этапа и обновления Ubuntu сохраняются, чтобы не потерять доступ к серверу. Языковые пакеты и дополнительные программы удаляются только если первый скрипт зафиксировал, что до его запуска их не было.
```bash ```bash
curl -fLO https://raw.githubusercontent.com/ochenstarik-ui/lightweight-server/main/ochenstarik-server-uninstall.sh
chmod 700 ochenstarik-server-uninstall.sh chmod 700 ochenstarik-server-uninstall.sh
bash -n ochenstarik-server-uninstall.sh bash -n ochenstarik-server-uninstall.sh
sudo ./ochenstarik-server-uninstall.sh sudo ./ochenstarik-server-uninstall.sh

View file

@ -109,11 +109,44 @@ add_managed_rule() {
RULE_SEEN["$rule"]=1 RULE_SEEN["$rule"]=1
} }
discover_current_ssh_ports() {
local managed_file="${1:-$MANAGED_PORTS_CONFIG}"
local client_ip client_port server_ip server_port port
local -A seen_ports=()
if [[ -n "${SSH_CONNECTION:-}" ]]; then
read -r client_ip client_port server_ip server_port <<< "$SSH_CONNECTION"
if is_valid_port "${server_port:-}"; then
printf '%s\n' "$((10#$server_port))"
return 0
fi
fi
if command -v ss >/dev/null 2>&1; then
while IFS= read -r port; do
is_valid_port "$port" || continue
port="$((10#$port))"
[[ -z "${seen_ports[$port]:-}" ]] || continue
printf '%s\n' "$port"
seen_ports["$port"]=1
done < <(ss -H -ltnp 2>/dev/null | awk '$NF ~ /sshd/ { address=$4; sub(/^.*:/, "", address); if (address ~ /^[0-9]+$/) print address }')
fi
if ((${#seen_ports[@]} == 0)) && [[ ! -e "$managed_file" ]]; then
# First run from sudo or a local console: preserve the conventional SSH
# port when no session metadata or listening socket is discoverable.
printf '22\n'
fi
}
load_managed_rules() { load_managed_rules() {
local rule port protocol local rule port protocol
MANAGED_RULES=() MANAGED_RULES=()
RULE_SEEN=() RULE_SEEN=()
[[ -e "$MANAGED_PORTS_CONFIG" ]] || return 0 PREVIOUS_MANAGED_RULES=()
if [[ ! -e "$MANAGED_PORTS_CONFIG" ]]; then
return 0
fi
[[ -f "$MANAGED_PORTS_CONFIG" && ! -L "$MANAGED_PORTS_CONFIG" ]] \ [[ -f "$MANAGED_PORTS_CONFIG" && ! -L "$MANAGED_PORTS_CONFIG" ]] \
|| die "$MANAGED_PORTS_CONFIG должен быть обычным файлом" || die "$MANAGED_PORTS_CONFIG должен быть обычным файлом"
@ -127,20 +160,58 @@ load_managed_rules() {
is_valid_port "$port" || die "Некорректный порт в $MANAGED_PORTS_CONFIG: $port" is_valid_port "$port" || die "Некорректный порт в $MANAGED_PORTS_CONFIG: $port"
add_managed_rule "$((10#$port))/${protocol}" add_managed_rule "$((10#$port))/${protocol}"
done < "$MANAGED_PORTS_CONFIG" done < "$MANAGED_PORTS_CONFIG"
PREVIOUS_MANAGED_RULES=("${MANAGED_RULES[@]}")
} }
add_default_rules() { add_default_rules() {
local saved_ssh_port local saved_ssh_port current_ssh_port
local -a current_ssh_ports=()
saved_ssh_port="${SSH_PORT:-$(read_saved_value "$SSH_PORT_CONFIG" SSH_PORT)}" saved_ssh_port="${SSH_PORT:-$(read_saved_value "$SSH_PORT_CONFIG" SSH_PORT)}"
is_valid_port "$saved_ssh_port" || saved_ssh_port="$DEFAULT_SSH_PORT" is_valid_port "$saved_ssh_port" || saved_ssh_port="$DEFAULT_SSH_PORT"
for rule in \ add_managed_rule "$((10#$saved_ssh_port))/tcp"
22/tcp "$((10#$saved_ssh_port))/tcp" 80/tcp 2096/tcp 443/tcp \ add_managed_rule 443/tcp
40000/tcp 63636/tcp; do
add_managed_rule "$rule" # Keep the port carrying this setup session open during an SSH migration.
mapfile -t current_ssh_ports < <(discover_current_ssh_ports)
for current_ssh_port in "${current_ssh_ports[@]}"; do
add_managed_rule "$current_ssh_port/tcp"
done done
} }
confirm_legacy_rules() {
local rule answer current_ssh_port
local -a legacy_rules=() retained_rules=()
local -a current_ssh_ports=()
local -A defaults=() seen=()
defaults["$((10#$SSH_PORT))/tcp"]=1
defaults[443/tcp]=1
mapfile -t current_ssh_ports < <(discover_current_ssh_ports)
for current_ssh_port in "${current_ssh_ports[@]}"; do defaults["$current_ssh_port/tcp"]=1; done
for rule in "${MANAGED_RULES[@]}"; do
if [[ -n "${defaults[$rule]:-}" ]]; then
retained_rules+=("$rule")
seen["$rule"]=1
else
legacy_rules+=("$rule")
fi
done
((${#legacy_rules[@]})) || return 0
printf '\nНайдены дополнительные правила UFW из прежней конфигурации; их назначение неизвестно:\n'
printf ' %s\n' "${legacy_rules[@]}"
read -rp 'Сохранить весь этот список? [y/N]: ' answer \
|| die "Ввод прерван; правила UFW не изменены"
if [[ "$answer" == y || "$answer" == Y ]]; then
retained_rules+=("${legacy_rules[@]}")
for rule in "${legacy_rules[@]}"; do seen["$rule"]=1; done
fi
MANAGED_RULES=("${retained_rules[@]}")
RULE_SEEN=()
for rule in "${!seen[@]}"; do RULE_SEEN["$rule"]=1; done
}
collect_additional_ports() { collect_additional_ports() {
local input normalized token port protocol rule local input normalized token port protocol rule
local -a tokens additions=() local -a tokens additions=()
@ -243,9 +314,9 @@ verify_ipv6_connectivity() {
} }
delete_managed_ufw_rules() { delete_managed_ufw_rules() {
local rule port protocol attempt local rule port protocol attempt status
for rule in "${MANAGED_RULES[@]}"; do for rule in "${PREVIOUS_MANAGED_RULES[@]}"; do
port="${rule%/*}" port="${rule%/*}"
protocol="${rule#*/}" protocol="${rule#*/}"
for attempt in {1..5}; do for attempt in {1..5}; do
@ -256,6 +327,15 @@ delete_managed_ufw_rules() {
ufw --force delete allow from ::/0 to any port "$port" proto "$protocol" \ ufw --force delete allow from ::/0 to any port "$port" proto "$protocol" \
>/dev/null 2>&1 || true >/dev/null 2>&1 || true
done done
status="$(LANG=C ufw status)" || die "Не удалось проверить состояние UFW после удаления старых правил"
for rule in "${PREVIOUS_MANAGED_RULES[@]}"; do
if awk -v rule="$rule" \
'$1 == rule && ($2 == "ALLOW" || ($2 == "(v6)" && $3 == "ALLOW")) { found=1 } END { exit !found }' \
<<< "$status"; then
die "Старое правило UFW ${rule} всё ещё разрешено; новая конфигурация не сохранена"
fi
done
} }
allow_rule_for_selected_families() { allow_rule_for_selected_families() {
@ -303,6 +383,7 @@ apply_ip_mode_and_firewall() {
[[ "$EUID" -eq 0 ]] || die "Запустите этот скрипт от имени root" [[ "$EUID" -eq 0 ]] || die "Запустите этот скрипт от имени root"
declare -a MANAGED_RULES=() declare -a MANAGED_RULES=()
declare -a PREVIOUS_MANAGED_RULES=()
declare -A RULE_SEEN=() declare -A RULE_SEEN=()
ACTION="" ACTION=""
SSH_PORT="" SSH_PORT=""
@ -327,9 +408,11 @@ if [[ "$ACTION" == install ]]; then
done done
install -d -m 700 -o root -g root "$CONFIG_DIR" install -d -m 700 -o root -g root "$CONFIG_DIR"
load_managed_rules load_managed_rules
PREVIOUS_MANAGED_RULES=("${MANAGED_RULES[@]}")
confirm_legacy_rules
add_default_rules add_default_rules
save_configuration
apply_ip_mode_and_firewall apply_ip_mode_and_firewall
save_configuration
log "SSH-порт ${SSH_PORT} сохранён для этапа 3" log "SSH-порт ${SSH_PORT} сохранён для этапа 3"
else else
for command_name in ip sed sysctl ufw; do for command_name in ip sed sysctl ufw; do
@ -339,6 +422,8 @@ else
SSH_PORT="$(read_saved_value "$SSH_PORT_CONFIG" SSH_PORT)" SSH_PORT="$(read_saved_value "$SSH_PORT_CONFIG" SSH_PORT)"
is_valid_port "$SSH_PORT" || SSH_PORT="$DEFAULT_SSH_PORT" is_valid_port "$SSH_PORT" || SSH_PORT="$DEFAULT_SSH_PORT"
load_managed_rules load_managed_rules
PREVIOUS_MANAGED_RULES=("${MANAGED_RULES[@]}")
confirm_legacy_rules
add_default_rules add_default_rules
if [[ "$ACTION" == ip-mode ]]; then if [[ "$ACTION" == ip-mode ]]; then
@ -348,8 +433,8 @@ else
case "$IP_MODE" in ipv4|ipv6|both) ;; *) choose_ip_mode ;; esac case "$IP_MODE" in ipv4|ipv6|both) ;; *) choose_ip_mode ;; esac
collect_additional_ports collect_additional_ports
fi fi
save_configuration
apply_ip_mode_and_firewall apply_ip_mode_and_firewall
save_configuration
fi fi
log "Текущая конфигурация" log "Текущая конфигурация"

View file

@ -77,6 +77,54 @@ choose_backup_root() {
esac esac
} }
choose_protection() {
local answer known_hosts
read -rp 'Шифровать архивы через age? [y/N]: ' answer || die "Ввод был прерван"
case "${answer,,}" in
y|yes)
BACKUP_ENCRYPTION=age
read -rp 'Публичный age recipient (age1...): ' AGE_RECIPIENT \
|| die "Ввод был прерван"
[[ "$AGE_RECIPIENT" =~ ^age1[0-9a-z]{20,}$ ]] \
|| die "Укажите корректный публичный age recipient"
;;
''|n|no) BACKUP_ENCRYPTION=none ;;
*) die "Введите y или n" ;;
esac
read -rp 'Выгружать архивы на другой SSH-хост? [y/N]: ' answer \
|| die "Ввод был прерван"
case "${answer,,}" in
y|yes)
read -rp 'SSH-цель в формате user@host: ' OFFSITE_TARGET \
|| die "Ввод был прерван"
[[ "$OFFSITE_TARGET" =~ ^[A-Za-z0-9._-]+@[A-Za-z0-9.-]+$ ]] \
|| die "Некорректная SSH-цель"
read -rp 'Каталог на целевом хосте (абсолютный путь): ' OFFSITE_PATH \
|| die "Ввод был прерван"
[[ "$OFFSITE_PATH" =~ ^/[A-Za-z0-9._/-]+$ && "$OFFSITE_PATH" != / \
&& "$OFFSITE_PATH" != *..* ]] \
|| die "Некорректный удалённый каталог"
read -rp 'Локальный файл known_hosts с заранее проверенным ключом: ' known_hosts \
|| die "Ввод был прерван"
[[ -f "$known_hosts" && ! -L "$known_hosts" && -s "$known_hosts" ]] \
|| die "Нужен существующий непустой обычный known_hosts файл"
[[ "$(stat -c %u "$known_hosts")" == 0 ]] \
|| die "known_hosts должен принадлежать root"
[[ "$(stat -c %a "$known_hosts")" =~ ^[0-7]*[0-5][0-5]$ ]] \
|| die "known_hosts не должен быть доступен для записи группе или всем"
OFFSITE_KNOWN_HOSTS_SOURCE="$(readlink -f -- "$known_hosts")"
OFFSITE_KNOWN_HOSTS="${CONFIG_DIR}/backup-known_hosts"
;;
''|n|no)
OFFSITE_TARGET=""; OFFSITE_PATH=""; OFFSITE_KNOWN_HOSTS=""
OFFSITE_KNOWN_HOSTS_SOURCE=""
;;
*) die "Введите y или n" ;;
esac
}
write_backup_runner() { write_backup_runner() {
cat > "$BACKUP_RUNNER" <<'BACKUP_RUNNER_EOF' cat > "$BACKUP_RUNNER" <<'BACKUP_RUNNER_EOF'
#!/usr/bin/env bash #!/usr/bin/env bash
@ -99,13 +147,30 @@ protect_initial_files() {
local file local file
command -v chattr >/dev/null 2>&1 || return 0 command -v chattr >/dev/null 2>&1 || return 0
for file in "$BACKUP_ROOT"/initial/initial-*.tar.zst \ for file in "$BACKUP_ROOT"/initial/initial-*.tar.zst \
"$BACKUP_ROOT"/initial/initial-*.tar.zst.sha256; do "$BACKUP_ROOT"/initial/initial-*.tar.zst.sha256 \
[[ -e "$file" ]] || continue "$BACKUP_ROOT"/initial/initial-*.tar.zst.age \
"$BACKUP_ROOT"/initial/initial-*.tar.zst.age.sha256; do
[[ -f "$file" && ! -L "$file" ]] || continue
chattr +i -- "$file" 2>/dev/null \ chattr +i -- "$file" 2>/dev/null \
|| warn "Файловая система не поддерживает immutable для $file" || warn "Файловая система не поддерживает immutable для $file"
done done
} }
upload_offsite() {
local archive="$1" checksum="$2"
[[ -z "$OFFSITE_TARGET" ]] && return 0
scp -B \
-o BatchMode=yes \
-o ConnectTimeout=15 \
-o StrictHostKeyChecking=yes \
-o "UserKnownHostsFile=$OFFSITE_KNOWN_HOSTS" \
-o ForwardAgent=no \
-o ClearAllForwardings=yes \
"$archive" "$checksum" "${OFFSITE_TARGET}:${OFFSITE_PATH}/" \
|| die "Выгрузка на заданный SSH-хост не удалась; резервная копия не считается завершённой"
log "Архив и checksum выгружены на настроенный SSH-хост"
}
check_free_space() { check_free_space() {
local root_device backup_device root_used backup_used source_used local root_device backup_device root_used backup_used source_used
local available reserve estimated_required backup_total local available reserve estimated_required backup_total
@ -136,7 +201,8 @@ rotate_backups() {
local -a archives local -a archives
mapfile -t archives < <( mapfile -t archives < <(
find "$target_dir" -maxdepth 1 -type f -name "${backup_kind}-*.tar.zst" \ find "$target_dir" -maxdepth 1 -type f \
\( -name "${backup_kind}-*.tar.zst" -o -name "${backup_kind}-*.tar.zst.age" \) \
-printf '%f\n' | sort -r -printf '%f\n' | sort -r
) )
for ((index = keep_count; index < ${#archives[@]}; index++)); do for ((index = keep_count; index < ${#archives[@]}; index++)); do
@ -149,6 +215,12 @@ rotate_backups() {
read_backup_config() { read_backup_config() {
local line key value local line key value
BACKUP_ROOT="" BACKUP_ROOT=""
BACKUP_ENCRYPTION=none
AGE_RECIPIENT=""
OFFSITE_TARGET=""
OFFSITE_PATH=""
OFFSITE_KNOWN_HOSTS=""
OFFSITE_KNOWN_HOSTS_SOURCE=""
[[ -f "$CONFIG_FILE" && ! -L "$CONFIG_FILE" ]] \ [[ -f "$CONFIG_FILE" && ! -L "$CONFIG_FILE" ]] \
|| die "Не найден безопасный файл настроек: $CONFIG_FILE" || die "Не найден безопасный файл настроек: $CONFIG_FILE"
[[ "$(stat -c %u "$CONFIG_FILE")" == 0 ]] \ [[ "$(stat -c %u "$CONFIG_FILE")" == 0 ]] \
@ -168,30 +240,92 @@ read_backup_config() {
|| die "Некорректный BACKUP_ROOT в $CONFIG_FILE" || die "Некорректный BACKUP_ROOT в $CONFIG_FILE"
BACKUP_ROOT="$value" BACKUP_ROOT="$value"
;; ;;
BACKUP_ENCRYPTION)
[[ "$value" == none || "$value" == age ]] \
|| die "Некорректный BACKUP_ENCRYPTION в $CONFIG_FILE"
BACKUP_ENCRYPTION="$value"
;;
AGE_RECIPIENT)
[[ "$value" =~ ^age1[0-9a-z]{20,}$ ]] \
|| die "Некорректный AGE_RECIPIENT в $CONFIG_FILE"
AGE_RECIPIENT="$value"
;;
OFFSITE_TARGET)
[[ "$value" =~ ^[A-Za-z0-9._-]+@[A-Za-z0-9.-]+$ ]] \
|| die "Некорректный OFFSITE_TARGET в $CONFIG_FILE"
OFFSITE_TARGET="$value"
;;
OFFSITE_PATH)
[[ "$value" =~ ^/[A-Za-z0-9._/-]+$ && "$value" != / \
&& "$value" != *..* ]] \
|| die "Некорректный OFFSITE_PATH в $CONFIG_FILE"
OFFSITE_PATH="$value"
;;
OFFSITE_KNOWN_HOSTS)
[[ "$value" =~ ^/[A-Za-z0-9._/-]+$ ]] \
|| die "Некорректный OFFSITE_KNOWN_HOSTS в $CONFIG_FILE"
OFFSITE_KNOWN_HOSTS="$value"
;;
*) *)
die "Неизвестный ключ в $CONFIG_FILE: $key" die "Неизвестный ключ в $CONFIG_FILE: $key"
;; ;;
esac esac
done < "$CONFIG_FILE" done < "$CONFIG_FILE"
if [[ "$BACKUP_ENCRYPTION" == age ]]; then
[[ -n "$AGE_RECIPIENT" ]] || die "Для шифрования нужен AGE_RECIPIENT"
command -v age >/dev/null 2>&1 || die "Для шифрования не найдена команда age"
elif [[ -n "$AGE_RECIPIENT" ]]; then
die "AGE_RECIPIENT задан, но шифрование отключено"
fi
if [[ -n "$OFFSITE_TARGET" || -n "$OFFSITE_PATH" || -n "$OFFSITE_KNOWN_HOSTS" ]]; then
[[ -n "$OFFSITE_TARGET" && -n "$OFFSITE_PATH" && -n "$OFFSITE_KNOWN_HOSTS" ]] \
|| die "Настройки выгрузки должны быть заполнены полностью"
[[ -f "$OFFSITE_KNOWN_HOSTS" && ! -L "$OFFSITE_KNOWN_HOSTS" \
&& -s "$OFFSITE_KNOWN_HOSTS" && "$(stat -c %u "$OFFSITE_KNOWN_HOSTS")" == 0 \
&& "$(stat -c %a "$OFFSITE_KNOWN_HOSTS")" =~ ^[0-7]*[0-5][0-5]$ ]] \
|| die "known_hosts отсутствует или имеет небезопасные права"
command -v scp >/dev/null 2>&1 || die "Для выгрузки не найдена команда scp"
fi
} }
create_backup() { create_backup() {
local backup_kind="$1" keep_count="$2" timestamp target_dir local backup_kind="$1" keep_count="$2" timestamp target_dir
local archive temporary checksum checksum_temporary exclude_backup_root local archive temporary checksum checksum_temporary exclude_backup_root existing
local -a initial_archives
target_dir="${BACKUP_ROOT}/${backup_kind}" target_dir="${BACKUP_ROOT}/${backup_kind}"
mkdir -p -m 700 -- "$target_dir" mkdir -p -m 700 -- "$target_dir"
if [[ "$backup_kind" == initial ]] && if [[ "$backup_kind" == initial ]]; then
find "$target_dir" -maxdepth 1 -type f -name 'initial-*.tar.zst' -print -quit | grep -q .; then mapfile -t initial_archives < <(
log "Первичный снимок уже существует и не будет перезаписан" find "$target_dir" -maxdepth 1 -type f \
\( -name 'initial-*.tar.zst' -o -name 'initial-*.tar.zst.age' \) \
-printf '%f\n' | sort -r
)
if ((${#initial_archives[@]} > 0)); then
existing="${target_dir}/${initial_archives[0]}"
checksum="${existing}.sha256"
[[ -f "$existing" && ! -L "$existing" \
&& -f "$checksum" && ! -L "$checksum" ]] \
|| die "Первичный снимок или его контрольная сумма отсутствует либо небезопасна"
(
cd -- "$target_dir"
sha256sum --check --status "$(basename "$checksum")"
) || die "Контрольная сумма первичного снимка не совпадает"
if [[ "$BACKUP_ENCRYPTION" == age && "$existing" != *.tar.zst.age ]]; then
die "Первичный снимок уже создан без шифрования; он сохранён, но не соответствует выбранной политике age. Укажите прежнюю политику или архивируйте систему отдельно."
fi
protect_initial_files protect_initial_files
upload_offsite "$existing" "$checksum"
log "Первичный снимок уже существует и не будет перезаписан; checksum проверен"
return 0 return 0
fi fi
fi
check_free_space check_free_space
timestamp="$(date +%Y%m%d-%H%M%S)" timestamp="$(date +%Y%m%d-%H%M%S)"
archive="${target_dir}/${backup_kind}-${timestamp}.tar.zst" archive="${target_dir}/${backup_kind}-${timestamp}.tar.zst"
[[ "$BACKUP_ENCRYPTION" != age ]] || archive="${archive}.age"
temporary="${archive}.partial" temporary="${archive}.partial"
checksum="${archive}.sha256" checksum="${archive}.sha256"
checksum_temporary="${checksum}.partial" checksum_temporary="${checksum}.partial"
@ -233,7 +367,13 @@ create_backup() {
--exclude='./swapfile' \ --exclude='./swapfile' \
--exclude="$exclude_backup_root" \ --exclude="$exclude_backup_root" \
--exclude="${exclude_backup_root}/*" \ --exclude="${exclude_backup_root}/*" \
. | zstd -T0 -3 --quiet -o "$temporary" . | zstd -T0 -3 --quiet | {
if [[ "$BACKUP_ENCRYPTION" == age ]]; then
age --encrypt --recipient "$AGE_RECIPIENT" --output "$temporary"
else
cat > "$temporary"
fi
}
[[ -s "$temporary" ]] || die "Получился пустой архив" [[ -s "$temporary" ]] || die "Получился пустой архив"
chmod 600 "$temporary" chmod 600 "$temporary"
@ -251,6 +391,7 @@ create_backup() {
else else
rotate_backups "$backup_kind" "$keep_count" rotate_backups "$backup_kind" "$keep_count"
fi fi
upload_offsite "$archive" "$checksum"
log "Готово: $archive ($(human_size "$(stat -c %s "$archive")"))" log "Готово: $archive ($(human_size "$(stat -c %s "$archive")"))"
} }
@ -376,18 +517,35 @@ done
choose_schedules choose_schedules
choose_backup_root choose_backup_root
choose_protection
export DEBIAN_FRONTEND=noninteractive export DEBIAN_FRONTEND=noninteractive
log "Устанавливаю зависимости" log "Устанавливаю зависимости"
apt-get update apt-get update
apt-get install -y coreutils e2fsprogs findutils tar util-linux zstd apt-get install -y coreutils e2fsprogs findutils tar util-linux zstd
[[ "$BACKUP_ENCRYPTION" != age ]] || apt-get install -y age
[[ -z "$OFFSITE_TARGET" ]] || apt-get install -y openssh-client
for command_name in awk basename chattr df du find flock grep numfmt sha256sum sort stat tar zstd; do for command_name in awk basename chattr df du find flock grep numfmt sha256sum sort stat tar zstd; do
require_command "$command_name" require_command "$command_name"
done done
install -d -o root -g root -m 700 "$CONFIG_DIR" "$BACKUP_ROOT" install -d -o root -g root -m 700 "$CONFIG_DIR" "$BACKUP_ROOT"
if [[ -n "$OFFSITE_TARGET" ]]; then
[[ ! -L "$OFFSITE_KNOWN_HOSTS" ]] \
|| die "Отказ от записи через символическую ссылку: $OFFSITE_KNOWN_HOSTS"
install -o root -g root -m 600 -- "$OFFSITE_KNOWN_HOSTS_SOURCE" "$OFFSITE_KNOWN_HOSTS"
fi
[[ ! -L "$CONFIG_FILE" ]] || die "Отказ от записи через символическую ссылку: $CONFIG_FILE" [[ ! -L "$CONFIG_FILE" ]] || die "Отказ от записи через символическую ссылку: $CONFIG_FILE"
printf 'BACKUP_ROOT=%s\n' "$BACKUP_ROOT" > "$CONFIG_FILE" {
printf 'BACKUP_ROOT=%s\n' "$BACKUP_ROOT"
printf 'BACKUP_ENCRYPTION=%s\n' "$BACKUP_ENCRYPTION"
[[ "$BACKUP_ENCRYPTION" != age ]] || printf 'AGE_RECIPIENT=%s\n' "$AGE_RECIPIENT"
if [[ -n "$OFFSITE_TARGET" ]]; then
printf 'OFFSITE_TARGET=%s\n' "$OFFSITE_TARGET"
printf 'OFFSITE_PATH=%s\n' "$OFFSITE_PATH"
printf 'OFFSITE_KNOWN_HOSTS=%s\n' "$OFFSITE_KNOWN_HOSTS"
fi
} > "$CONFIG_FILE"
chown root:root "$CONFIG_FILE" chown root:root "$CONFIG_FILE"
chmod 600 "$CONFIG_FILE" chmod 600 "$CONFIG_FILE"

View file

@ -1,7 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -Eeuo pipefail set -Eeuo pipefail
readonly XUI_INSTALL_URL="https://raw.githubusercontent.com/MHSanaei/3x-ui/main/install.sh"
readonly XUI_BINARY="/usr/local/x-ui/x-ui" readonly XUI_BINARY="/usr/local/x-ui/x-ui"
readonly XUI_DB="/etc/x-ui/x-ui.db" readonly XUI_DB="/etc/x-ui/x-ui.db"
readonly CLOUDFLARE_KEY_URL="https://pkg.cloudflareclient.com/pubkey.gpg" readonly CLOUDFLARE_KEY_URL="https://pkg.cloudflareclient.com/pubkey.gpg"
@ -13,8 +12,12 @@ readonly DEFAULT_WARP_PORT="40000"
readonly SSH_PORT_CONFIG="/etc/ochenstarik-server/ssh-port.conf" readonly SSH_PORT_CONFIG="/etc/ochenstarik-server/ssh-port.conf"
readonly IP_FAMILY_CONFIG="/etc/ochenstarik-server/ip-family.conf" readonly IP_FAMILY_CONFIG="/etc/ochenstarik-server/ip-family.conf"
readonly MANAGED_PORTS_CONFIG="/etc/ochenstarik-server/ufw-managed-ports.conf" readonly MANAGED_PORTS_CONFIG="/etc/ochenstarik-server/ufw-managed-ports.conf"
readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
readonly SUPPLY_CHAIN_HELPER="${SCRIPT_DIR}/ochenstarik-server-supply-chain.sh"
TMP_DIR="" TMP_DIR=""
XUI_GUARD_DIR=""
XUI_BOOTSTRAP_RELEASE_MARKER=""
log() { printf '[+] %s\n' "$*"; } log() { printf '[+] %s\n' "$*"; }
warn() { printf '[!] %s\n' "$*" >&2; } warn() { printf '[!] %s\n' "$*" >&2; }
@ -112,19 +115,139 @@ wait_for_tcp_listener() {
die "${description} не открыл TCP-порт ${port}" die "${description} не открыл TCP-порт ${port}"
} }
verify_private_listener() {
local port="$1" description="$2" listeners address found=no
listeners="$(ss -H -ltn "sport = :${port}")"
[[ -n "$listeners" ]] || die "${description} не слушает TCP-порт ${port}"
while read -r address; do
[[ -n "$address" ]] || continue
[[ "$address" == "127.0.0.1:${port}" ]] \
|| die "${description} слушает внешний или IPv6-адрес ${address}; ожидался только 127.0.0.1:${port}"
found=yes
done < <(awk '{print $4}' <<< "$listeners")
[[ "$found" == yes ]] || die "Не удалось проверить loopback bind ${port}"
}
valid_subscription_path() {
local path="$1"
[[ -n "$path" && ${#path} -le 1024 && "$path" =~ ^/?[A-Za-z0-9._~-]+(/[A-Za-z0-9._~-]+)*/?$ ]] \
|| return 1
[[ "$path" != *'..'* && "$path" != *'//'* ]]
}
secure_public_subscription_path() {
local path="$1" component
component="${path#/}"
component="${component%/}"
[[ "$component" =~ ^[A-Za-z0-9_-]{32,}$ ]]
}
generate_subscription_path() {
local secret
secret="$(openssl rand -hex 24)" || die "Не удалось создать случайный путь подписки"
[[ "$secret" =~ ^[0-9a-f]{48}$ ]] || die "Генератор создал путь подписки неожиданного формата"
XUI_SUB_PATH="/${secret}/"
}
load_existing_subscription_path() {
local db="${1:-$XUI_DB}"
XUI_EXISTING_SUB_PATH=""
[[ -f "$db" && ! -L "$db" ]] || return 0
XUI_EXISTING_SUB_PATH="$(sqlite3 "$db" "SELECT value FROM settings WHERE key='subPath' LIMIT 1;" 2>/dev/null || true)"
valid_subscription_path "$XUI_EXISTING_SUB_PATH" || XUI_EXISTING_SUB_PATH=""
}
choose_public_access() { choose_public_access() {
local answer local answer
printf '\nДоступ к панели 3x-ui и подпискам:\n' printf '\nДоступ к панели 3x-ui:\n'
printf ' 1) Приватный режим: не открывать порты панели и подписок в UFW (рекомендуется)\n' printf ' 1) Приватный режим: слушать только 127.0.0.1, доступ через SSH tunnel (рекомендуется)\n'
printf ' 2) Публичный режим: открыть порты панели и подписок всему Интернету\n' printf ' 2) Публичный режим: слушать все интерфейсы и открыть порт панели в UFW\n'
while :; do while :; do
read -rp 'Режим доступа [1]: ' answer read -rp 'Доступ к панели [1]: ' answer || answer=1
answer="${answer:-1}" answer="${answer:-1}"
case "$answer" in case "$answer" in
1) XUI_PUBLIC_ACCESS=no; return 0 ;; 1) XUI_PUBLIC_ACCESS=no; XUI_WEB_LISTEN=127.0.0.1; return 0 ;;
2) 2)
warn "Публичный доступ к панели опасен. Используйте сложный путь, новые учётные данные, TLS и 2FA" warn "Публичная панель доступна из Интернета. Используйте TLS, новые учётные данные и 2FA"
XUI_PUBLIC_ACCESS=yes XUI_PUBLIC_ACCESS=yes
XUI_WEB_LISTEN=""
return 0
;;
*) warn "Введите 1 или 2" ;;
esac
done
}
choose_subscription_access() {
local answer rotate_answer existing_path="${XUI_EXISTING_SUB_PATH:-}"
printf '\nДоступ к подпискам 3x-ui (выбор не зависит от доступа к панели):\n'
printf ' 1) Приватный режим: включены только на 127.0.0.1 (рекомендуется)\n'
printf ' 2) Выключить сервис подписок\n'
printf ' 3) Публичный доступ: отдельный случайный секретный путь и открытый порт\n'
while :; do
read -rp 'Доступ к подпискам [1]: ' answer || answer=1
answer="${answer:-1}"
case "$answer" in
1)
XUI_SUBSCRIPTION_ACCESS=private
XUI_SUB_LISTEN=127.0.0.1
XUI_SUB_PATH="${existing_path:-/sub/}"
return 0
;;
2)
XUI_SUBSCRIPTION_ACCESS=disabled
XUI_SUB_LISTEN=127.0.0.1
XUI_SUB_PATH="${existing_path:-/sub/}"
return 0
;;
3)
command -v openssl >/dev/null 2>&1 || die "Для случайного пути подписки нужна команда openssl"
if [[ -n "$existing_path" ]] && secure_public_subscription_path "$existing_path"; then
warn "Найден действующий секретный путь подписки; его сохранение оставит существующие URL рабочими"
read -rp 'Создать новый путь и отключить старые URL? [y/N]: ' rotate_answer || rotate_answer=no
if [[ "$rotate_answer" == y || "$rotate_answer" == Y ]]; then
generate_subscription_path
else
XUI_SUB_PATH="$existing_path"
fi
elif [[ -n "$existing_path" ]]; then
warn "Текущий путь подписки не выглядит достаточно случайным. Ротация отключит существующие URL."
read -rp 'Создать новый криптографически случайный путь для публичного доступа? [y/N]: ' rotate_answer \
|| rotate_answer=no
if [[ "$rotate_answer" == y || "$rotate_answer" == Y ]]; then
generate_subscription_path
else
warn "Публичный доступ к подпискам не выбран; выберите приватный режим или отключение"
continue
fi
else
generate_subscription_path
fi
XUI_SUBSCRIPTION_ACCESS=public
XUI_SUB_LISTEN=""
return 0
;;
*) warn "Введите 1, 2 или 3" ;;
esac
done
}
choose_xui_source() {
local answer mirror_base
printf '\nИсточник проверенных установочных файлов 3x-ui и Xray:\n'
printf ' 1) GitHub (по умолчанию)\n'
printf ' 2) Forgejo: указать HTTPS base URL без учётных данных\n'
while :; do
read -rp 'Источник [1]: ' answer || answer=1
answer="${answer:-1}"
case "$answer" in
1) XUI_SUPPLY_SOURCE=github; XUI_SUPPLY_MIRROR=""; return 0 ;;
2)
read -rp 'Forgejo HTTPS base URL: ' mirror_base \
|| die "Forgejo URL не получен"
[[ -n "$mirror_base" ]] || die "Forgejo URL не может быть пустым"
XUI_SUPPLY_SOURCE=forgejo
XUI_SUPPLY_MIRROR="$mirror_base"
return 0 return 0
;; ;;
*) warn "Введите 1 или 2" ;; *) warn "Введите 1 или 2" ;;
@ -171,8 +294,127 @@ COMMIT;
SQL SQL
} }
set_xui_desired_listeners() {
if [[ "$XUI_PUBLIC_ACCESS" == yes ]]; then
XUI_DESIRED_WEB_LISTEN=""
else
XUI_DESIRED_WEB_LISTEN=127.0.0.1
fi
if [[ "$XUI_SUBSCRIPTION_ACCESS" == public ]]; then
XUI_DESIRED_SUB_LISTEN=""
else
XUI_DESIRED_SUB_LISTEN=127.0.0.1
fi
if [[ "$XUI_SUBSCRIPTION_ACCESS" == disabled ]]; then
XUI_DESIRED_SUB_ENABLED=false
else
XUI_DESIRED_SUB_ENABLED=true
fi
}
create_xui_start_guard() {
local directory="$1" marker="$2" binary="$3" db="$4"
local real_systemctl="${5:-$(command -v systemctl)}"
local real_rc_service="${6:-$(command -v rc-service || true)}"
local real_sqlite3="${7:-$(command -v sqlite3)}"
local systemctl_q marker_q binary_q db_q rc_service_q sqlite3_q
[[ -x "$real_systemctl" ]] || die "systemctl недоступен для безопасной установки 3x-ui"
[[ -x "$real_sqlite3" ]] || die "sqlite3 недоступен для безопасной установки 3x-ui"
mkdir -m 700 -p -- "$directory"
printf -v systemctl_q '%q' "$real_systemctl"
printf -v marker_q '%q' "$marker"
printf -v binary_q '%q' "$binary"
printf -v db_q '%q' "$db"
printf -v rc_service_q '%q' "$real_rc_service"
printf -v sqlite3_q '%q' "$real_sqlite3"
cat > "$directory/systemctl" <<EOF
#!/usr/bin/env bash
set -Eeuo pipefail
REAL_SYSTEMCTL=${systemctl_q}
RELEASE_MARKER=${marker_q}
XUI_BINARY=${binary_q}
XUI_DB=${db_q}
SQLITE3_BIN=${sqlite3_q}
service=""
case "\${1:-}" in
start|restart) service="\${2:-}" ;;
enable)
for argument in "\$@"; do
[[ "\$argument" != x-ui && "\$argument" != x-ui.service ]] || service=x-ui
done
;;
esac
if [[ ( "\$service" == x-ui || "\$service" == x-ui.service ) && ! -e "\$RELEASE_MARKER" ]]; then
[[ -x "\$XUI_BINARY" ]] || { printf '[x] x-ui binary missing before guarded service start\\n' >&2; exit 1; }
"\$XUI_BINARY" setting -listenIP 127.0.0.1 \\
|| { printf '[x] Refusing x-ui start: loopback bind could not be applied\\n' >&2; exit 1; }
[[ -f "\$XUI_DB" && ! -L "\$XUI_DB" ]] \\
|| { printf '[x] Refusing x-ui start: settings database is unavailable\\n' >&2; exit 1; }
"\$SQLITE3_BIN" "\$XUI_DB" <<'SQL' || { printf '[x] Refusing x-ui start: safe subscription bind could not be applied\\n' >&2; exit 1; }
BEGIN IMMEDIATE;
UPDATE settings SET value='127.0.0.1' WHERE key='subListen';
INSERT INTO settings (key,value) SELECT 'subListen','127.0.0.1'
WHERE NOT EXISTS (SELECT 1 FROM settings WHERE key='subListen');
UPDATE settings SET value='false' WHERE key='subEnable';
INSERT INTO settings (key,value) SELECT 'subEnable','false'
WHERE NOT EXISTS (SELECT 1 FROM settings WHERE key='subEnable');
COMMIT;
SQL
safe_sub_settings="\$("\$SQLITE3_BIN" "\$XUI_DB" "SELECT (SELECT value FROM settings WHERE key='subListen' LIMIT 1) || '|' || (SELECT value FROM settings WHERE key='subEnable' LIMIT 1);")"
[[ "\$safe_sub_settings" == '127.0.0.1|false' ]] \\
|| { printf '[x] Refusing x-ui start: safe subscription settings did not verify\\n' >&2; exit 1; }
fi
exec "\$REAL_SYSTEMCTL" "\$@"
EOF
chmod 700 "$directory/systemctl"
cat > "$directory/rc-service" <<EOF
#!/usr/bin/env bash
set -Eeuo pipefail
REAL_RC_SERVICE=${rc_service_q}
RELEASE_MARKER=${marker_q}
XUI_BINARY=${binary_q}
XUI_DB=${db_q}
SQLITE3_BIN=${sqlite3_q}
if [[ "\${1:-}" == x-ui && ( "\${2:-}" == start || "\${2:-}" == restart ) \\
&& ! -e "\$RELEASE_MARKER" ]]; then
[[ -x "\$XUI_BINARY" ]] || { printf '[x] x-ui binary missing before guarded service start\\n' >&2; exit 1; }
"\$XUI_BINARY" setting -listenIP 127.0.0.1 \\
|| { printf '[x] Refusing x-ui start: loopback bind could not be applied\\n' >&2; exit 1; }
[[ -f "\$XUI_DB" && ! -L "\$XUI_DB" ]] \\
|| { printf '[x] Refusing x-ui start: settings database is unavailable\\n' >&2; exit 1; }
"\$SQLITE3_BIN" "\$XUI_DB" <<'SQL' || { printf '[x] Refusing x-ui start: safe subscription bind could not be applied\\n' >&2; exit 1; }
BEGIN IMMEDIATE;
UPDATE settings SET value='127.0.0.1' WHERE key='subListen';
INSERT INTO settings (key,value) SELECT 'subListen','127.0.0.1'
WHERE NOT EXISTS (SELECT 1 FROM settings WHERE key='subListen');
UPDATE settings SET value='false' WHERE key='subEnable';
INSERT INTO settings (key,value) SELECT 'subEnable','false'
WHERE NOT EXISTS (SELECT 1 FROM settings WHERE key='subEnable');
COMMIT;
SQL
safe_sub_settings="\$("\$SQLITE3_BIN" "\$XUI_DB" "SELECT (SELECT value FROM settings WHERE key='subListen' LIMIT 1) || '|' || (SELECT value FROM settings WHERE key='subEnable' LIMIT 1);")"
[[ "\$safe_sub_settings" == '127.0.0.1|false' ]] \\
|| { printf '[x] Refusing x-ui start: safe subscription settings did not verify\\n' >&2; exit 1; }
fi
[[ -x "\$REAL_RC_SERVICE" ]] || { printf '[x] rc-service is unavailable\\n' >&2; exit 127; }
exec "\$REAL_RC_SERVICE" "\$@"
EOF
chmod 700 "$directory/rc-service"
XUI_GUARD_DIR="$directory"
XUI_BOOTSTRAP_RELEASE_MARKER="$marker"
}
configure_xui_ports() { configure_xui_ports() {
local backup_file panel_value subscription_value local backup_file panel_value subscription_value subscription_listeners
# XUI_WEB_LISTEN and XUI_SUB_LISTEN are changed to safe loopback values
# during bootstrap. Recompute the requested final values from the explicit
# access choices before entering that protected phase.
set_xui_desired_listeners
[[ -x "$XUI_BINARY" ]] || die "После установки не найден $XUI_BINARY" [[ -x "$XUI_BINARY" ]] || die "После установки не найден $XUI_BINARY"
[[ -f "$XUI_DB" && ! -L "$XUI_DB" ]] \ [[ -f "$XUI_DB" && ! -L "$XUI_DB" ]] \
@ -189,16 +431,21 @@ configure_xui_ports() {
die "Не удалось изменить порт панели 3x-ui" die "Не удалось изменить порт панели 3x-ui"
fi fi
if ! set_xui_setting subPort "$SUBSCRIPTION_PORT" \ # The guarded first start always uses loopback. Public panel access is
|| ! set_xui_setting subEnable true; then # enabled only after this protected listener has been observed.
if ! set_xui_setting webListen 127.0.0.1 \
|| ! set_xui_setting subPort "$SUBSCRIPTION_PORT" \
|| ! set_xui_setting subListen 127.0.0.1 \
|| ! set_xui_setting subPath "$XUI_SUB_PATH" \
|| ! set_xui_setting subEnable false; then
cp -a -- "$backup_file" "$XUI_DB" cp -a -- "$backup_file" "$XUI_DB"
systemctl start x-ui.service >/dev/null 2>&1 || true systemctl start x-ui.service >/dev/null 2>&1 || true
die "Не удалось изменить порт подписок 3x-ui; база восстановлена" die "Не удалось изменить порт подписок 3x-ui; база восстановлена"
fi fi
systemctl daemon-reload systemctl daemon-reload
systemctl enable --now x-ui.service systemctl enable x-ui.service
systemctl restart x-ui.service systemctl start x-ui.service
systemctl is-active --quiet x-ui.service || die "Служба x-ui не запущена" systemctl is-active --quiet x-ui.service || die "Служба x-ui не запущена"
panel_value="$(sqlite3 "$XUI_DB" "SELECT value FROM settings WHERE key='webPort' LIMIT 1;")" panel_value="$(sqlite3 "$XUI_DB" "SELECT value FROM settings WHERE key='webPort' LIMIT 1;")"
@ -207,10 +454,41 @@ configure_xui_ports() {
|| die "В базе 3x-ui указан неожиданный порт панели: ${panel_value:-пусто}" || die "В базе 3x-ui указан неожиданный порт панели: ${panel_value:-пусто}"
[[ "$subscription_value" == "$SUBSCRIPTION_PORT" ]] \ [[ "$subscription_value" == "$SUBSCRIPTION_PORT" ]] \
|| die "В базе 3x-ui указан неожиданный порт подписок: ${subscription_value:-пусто}" || die "В базе 3x-ui указан неожиданный порт подписок: ${subscription_value:-пусто}"
XUI_WEB_LISTEN="$(sqlite3 "$XUI_DB" "SELECT value FROM settings WHERE key='webListen' LIMIT 1;")"
XUI_SUB_LISTEN="$(sqlite3 "$XUI_DB" "SELECT value FROM settings WHERE key='subListen' LIMIT 1;")"
[[ "$XUI_WEB_LISTEN" == 127.0.0.1 ]] \
|| die "Первый запуск 3x-ui остановлен: webListen не равен 127.0.0.1"
[[ "$XUI_SUB_LISTEN" == 127.0.0.1 ]] \
|| die "Первый запуск 3x-ui остановлен: subListen не равен 127.0.0.1"
XUI_WEB_PATH="$(sqlite3 "$XUI_DB" "SELECT value FROM settings WHERE key='webBasePath' LIMIT 1;")" XUI_WEB_PATH="$(sqlite3 "$XUI_DB" "SELECT value FROM settings WHERE key='webBasePath' LIMIT 1;")"
XUI_SUB_PATH="$(sqlite3 "$XUI_DB" "SELECT value FROM settings WHERE key='subPath' LIMIT 1;")"
[[ "$XUI_WEB_PATH" == /* ]] || XUI_WEB_PATH="/${XUI_WEB_PATH}" [[ "$XUI_WEB_PATH" == /* ]] || XUI_WEB_PATH="/${XUI_WEB_PATH}"
[[ "$XUI_SUB_PATH" == /* ]] || XUI_SUB_PATH="/${XUI_SUB_PATH}" [[ "$XUI_SUB_PATH" == /* ]] || XUI_SUB_PATH="/${XUI_SUB_PATH}"
wait_for_tcp_listener "$PANEL_PORT" "Панель 3x-ui"
verify_private_listener "$PANEL_PORT" "Панель 3x-ui"
subscription_listeners="$(ss -H -ltn "sport = :${SUBSCRIPTION_PORT}")"
if [[ -n "$subscription_listeners" ]]; then
verify_private_listener "$SUBSCRIPTION_PORT" "Сервис подписок 3x-ui"
fi
systemctl stop x-ui.service
if ! set_xui_setting webListen "$XUI_DESIRED_WEB_LISTEN" \
|| ! set_xui_setting subListen "$XUI_DESIRED_SUB_LISTEN" \
|| ! set_xui_setting subEnable "$XUI_DESIRED_SUB_ENABLED"; then
die "Не удалось применить выбранный режим доступа 3x-ui"
fi
XUI_WEB_LISTEN="$(sqlite3 "$XUI_DB" "SELECT value FROM settings WHERE key='webListen' LIMIT 1;")"
XUI_SUB_LISTEN="$(sqlite3 "$XUI_DB" "SELECT value FROM settings WHERE key='subListen' LIMIT 1;")"
XUI_SUB_ENABLED="$(sqlite3 "$XUI_DB" "SELECT value FROM settings WHERE key='subEnable' LIMIT 1;")"
[[ "$XUI_WEB_LISTEN" == "$XUI_DESIRED_WEB_LISTEN" ]] \
|| die "Неожиданный конечный webListen в базе 3x-ui"
[[ "$XUI_SUB_LISTEN" == "$XUI_DESIRED_SUB_LISTEN" ]] \
|| die "Неожиданный конечный subListen в базе 3x-ui"
[[ "$XUI_SUB_ENABLED" == "$XUI_DESIRED_SUB_ENABLED" ]] \
|| die "Неожиданное конечное состояние сервиса подписок в базе 3x-ui"
: > "$XUI_BOOTSTRAP_RELEASE_MARKER"
systemctl start x-ui.service
systemctl is-active --quiet x-ui.service || die "Служба x-ui не запустилась с выбранными настройками"
} }
install_cloudflare_warp() { install_cloudflare_warp() {
@ -286,80 +564,118 @@ PANEL_PORT=""
SUBSCRIPTION_PORT="" SUBSCRIPTION_PORT=""
WARP_PORT="" WARP_PORT=""
XUI_PUBLIC_ACCESS=no XUI_PUBLIC_ACCESS=no
XUI_SUBSCRIPTION_ACCESS=private
XUI_WEB_LISTEN=127.0.0.1
XUI_SUB_LISTEN=127.0.0.1
XUI_WEB_PATH="/" XUI_WEB_PATH="/"
XUI_SUB_PATH="/sub/" XUI_SUB_PATH=""
XUI_EXISTING_SUB_PATH=""
choose_port PANEL_PORT "Порт панели 3x-ui" "$DEFAULT_PANEL_PORT" "${forbidden_common[@]}" choose_port PANEL_PORT "Порт панели 3x-ui" "$DEFAULT_PANEL_PORT" "${forbidden_common[@]}"
choose_port SUBSCRIPTION_PORT "Порт подписок 3x-ui" "$DEFAULT_SUBSCRIPTION_PORT" \ choose_port SUBSCRIPTION_PORT "Порт подписок 3x-ui" "$DEFAULT_SUBSCRIPTION_PORT" \
"${forbidden_common[@]}" "$PANEL_PORT" "${forbidden_common[@]}" "$PANEL_PORT"
choose_public_access
choose_xui_source
read -rp 'Установить Cloudflare WARP local proxy? [y/N]: ' WARP_ANSWER || WARP_ANSWER=no
if [[ "$WARP_ANSWER" == y || "$WARP_ANSWER" == Y ]]; then
choose_port WARP_PORT "Локальный proxy-порт WARP" "$DEFAULT_WARP_PORT" \ choose_port WARP_PORT "Локальный proxy-порт WARP" "$DEFAULT_WARP_PORT" \
"${forbidden_common[@]}" "$PANEL_PORT" "$SUBSCRIPTION_PORT" "${forbidden_common[@]}" "$PANEL_PORT" "$SUBSCRIPTION_PORT"
choose_public_access
printf '\nБудут использованы порты:\n'
printf ' 3x-ui panel: %s/tcp\n' "$PANEL_PORT"
printf ' 3x-ui subscriptions: %s/tcp\n' "$SUBSCRIPTION_PORT"
printf ' WARP local proxy: 127.0.0.1:%s/tcp\n\n' "$WARP_PORT"
if [[ "$XUI_PUBLIC_ACCESS" == yes ]]; then
printf ' UFW: порты панели и подписок будут открыты публично\n\n'
else else
printf ' UFW: порты панели и подписок не будут открыты публично\n\n' WARP_PORT=""
fi fi
export DEBIAN_FRONTEND=noninteractive export DEBIAN_FRONTEND=noninteractive
log "Установка системных зависимостей" log "Установка системных зависимостей"
apt-get update apt-get update
apt-get install -y ca-certificates curl gnupg iproute2 sqlite3 ufw apt-get install -y ca-certificates curl gnupg iproute2 openssl sqlite3 ufw
for command_name in curl gpg sqlite3 ss ufw; do for command_name in curl gpg openssl sqlite3 ss ufw; do
require_command "$command_name" require_command "$command_name"
done done
load_existing_subscription_path
choose_subscription_access
printf '\nБудут использованы порты:\n'
printf ' 3x-ui panel: %s/tcp\n' "$PANEL_PORT"
printf ' 3x-ui subscriptions: %s/tcp\n' "$SUBSCRIPTION_PORT"
if [[ -n "$WARP_PORT" ]]; then printf ' WARP local proxy: 127.0.0.1:%s/tcp\n' "$WARP_PORT"; else printf ' WARP: выключен\n'; fi
printf ' UFW: HTTPS 443 открыт; панель %s; подписки %s\n\n' \
"$([[ "$XUI_PUBLIC_ACCESS" == yes ]] && printf 'публично' || printf 'закрыта')" \
"$([[ "$XUI_SUBSCRIPTION_ACCESS" == public ]] && printf 'публично' || printf '%s' "$XUI_SUBSCRIPTION_ACCESS")"
TMP_DIR="$(mktemp -d)" TMP_DIR="$(mktemp -d)"
chmod 700 "$TMP_DIR" chmod 700 "$TMP_DIR"
[[ -f "$SUPPLY_CHAIN_HELPER" && ! -L "$SUPPLY_CHAIN_HELPER" ]] \
|| die "Не найден безопасный supply-chain helper: $SUPPLY_CHAIN_HELPER"
if [[ "$XUI_SUPPLY_SOURCE" == github ]]; then
bash "$SUPPLY_CHAIN_HELPER" prepare github "${TMP_DIR}/vendor"
else
bash "$SUPPLY_CHAIN_HELPER" prepare forgejo "${TMP_DIR}/vendor" "$XUI_SUPPLY_MIRROR"
fi
create_xui_start_guard "${TMP_DIR}/xui-start-guard" \
"${TMP_DIR}/release-xui-listener" "$XUI_BINARY" "$XUI_DB"
export PATH="${XUI_GUARD_DIR}:${PATH}"
log "Загрузка официального установщика 3x-ui" log "Установка 3x-ui из проверенного релиза v3.10.2"
XUI_INSTALLER="${TMP_DIR}/3x-ui-install.sh" bash "$SUPPLY_CHAIN_HELPER" install-xui "${TMP_DIR}/vendor"
curl --fail --silent --show-error --location \
--proto '=https' --tlsv1.2 "$XUI_INSTALL_URL" -o "$XUI_INSTALLER"
chmod 700 "$XUI_INSTALLER"
bash -n "$XUI_INSTALLER"
bash "$XUI_INSTALLER"
log "Настройка портов 3x-ui" log "Настройка портов 3x-ui"
configure_xui_ports configure_xui_ports
if [[ -n "$WARP_PORT" ]]; then
log "Установка и настройка Cloudflare WARP" log "Установка и настройка Cloudflare WARP"
install_cloudflare_warp install_cloudflare_warp
fi
log "Настройка UFW" log "Настройка UFW"
allow_ufw_port 80 "HTTP"
allow_ufw_port 443 "HTTPS" allow_ufw_port 443 "HTTPS"
if [[ "$XUI_PUBLIC_ACCESS" == yes ]]; then if [[ "$XUI_PUBLIC_ACCESS" == yes ]]; then
allow_ufw_port "$PANEL_PORT" "панель 3x-ui" allow_ufw_port "$PANEL_PORT" "публичная панель 3x-ui"
fi
if [[ "$XUI_SUBSCRIPTION_ACCESS" == public ]]; then
allow_ufw_port "$SUBSCRIPTION_PORT" "подписки 3x-ui" allow_ufw_port "$SUBSCRIPTION_PORT" "подписки 3x-ui"
else else
log "UFW: порты панели и подписок оставлены закрытыми для публичного Интернета" log "UFW: порт подписок оставлен закрытым для публичного Интернета"
fi fi
ufw --force enable ufw --force enable
for port in 80 443; do verify_ufw_port 443
verify_ufw_port "$port"
done
if [[ "$XUI_PUBLIC_ACCESS" == yes ]]; then if [[ "$XUI_PUBLIC_ACCESS" == yes ]]; then
for port in "$PANEL_PORT" "$SUBSCRIPTION_PORT"; do verify_ufw_port "$PANEL_PORT"
verify_ufw_port "$port" fi
done if [[ "$XUI_SUBSCRIPTION_ACCESS" == public ]]; then
verify_ufw_port "$SUBSCRIPTION_PORT"
fi fi
wait_for_tcp_listener "$PANEL_PORT" "Панель 3x-ui" wait_for_tcp_listener "$PANEL_PORT" "Панель 3x-ui"
if [[ "$XUI_PUBLIC_ACCESS" != yes ]]; then
verify_private_listener "$PANEL_PORT" "Панель 3x-ui"
fi
if [[ "$XUI_SUBSCRIPTION_ACCESS" != disabled ]]; then
wait_for_tcp_listener "$SUBSCRIPTION_PORT" "Сервис подписок 3x-ui" wait_for_tcp_listener "$SUBSCRIPTION_PORT" "Сервис подписок 3x-ui"
verify_warp_proxy if [[ "$XUI_SUBSCRIPTION_ACCESS" != public ]]; then
verify_private_listener "$SUBSCRIPTION_PORT" "Сервис подписок 3x-ui"
fi
fi
if [[ -n "$WARP_PORT" ]]; then verify_warp_proxy; fi
printf '\nУстановка завершена.\n' printf '\nУстановка завершена.\n'
if [[ "$XUI_PUBLIC_ACCESS" == yes ]]; then
printf 'Панель 3x-ui: http(s)://<server-ip>:%s%s\n' "$PANEL_PORT" "$XUI_WEB_PATH" printf 'Панель 3x-ui: http(s)://<server-ip>:%s%s\n' "$PANEL_PORT" "$XUI_WEB_PATH"
printf 'Подписки 3x-ui: http(s)://<server-ip>:%s%s\n' "$SUBSCRIPTION_PORT" "$XUI_SUB_PATH" else
printf 'Панель 3x-ui: http://127.0.0.1:%s%s (только локально)\n' "$PANEL_PORT" "$XUI_WEB_PATH"
printf 'SSH tunnel: ssh -L %s:127.0.0.1:%s <user>@<server-ip>\n' "$PANEL_PORT" "$PANEL_PORT"
fi
if [[ "$XUI_SUBSCRIPTION_ACCESS" == disabled ]]; then
printf 'Подписки 3x-ui: выключены\n'
elif [[ "$XUI_SUBSCRIPTION_ACCESS" == public ]]; then
printf 'Подписки 3x-ui: порт %s/tcp открыт; случайный путь сохранён в базе 3x-ui и не выведен в журнал\n' "$SUBSCRIPTION_PORT"
else
printf 'Подписки 3x-ui: http(s)://127.0.0.1:%s%s (только локально)\n' "$SUBSCRIPTION_PORT" "$XUI_SUB_PATH"
fi
if [[ -n "$WARP_PORT" ]]; then
printf 'Локальный WARP proxy: socks5://127.0.0.1:%s\n' "$WARP_PORT" printf 'Локальный WARP proxy: socks5://127.0.0.1:%s\n' "$WARP_PORT"
printf '\nПорт WARP не открыт публично в UFW: локальный proxy не требует аутентификации.\n' printf '\nПорт WARP не открыт публично в UFW: локальный proxy не требует аутентификации.\n'
if [[ "$XUI_PUBLIC_ACCESS" != yes ]]; then else
printf 'Порты панели и подписок не открыты публично. Используйте SSH tunnel, management VPN или повторный запуск с явным публичным режимом.\n' printf 'Cloudflare WARP не устанавливался.\n'
fi fi
printf 'Настройте TLS, сложный путь панели, новые учётные данные и двухфакторную аутентификацию в 3x-ui.\n' printf 'Настройте TLS, сложный путь панели, новые учётные данные и двухфакторную аутентификацию в 3x-ui.\n'

View file

@ -0,0 +1,13 @@
# Reviewed upstream release and SHA-256 pins. Supported architectures: amd64, arm64.
# Upstream installer repositories carry GPL-3.0 licenses; these pins fix exact bytes.
3x-ui-tag=v3.10.2
3x-ui-installer-sha256=9684bae4f769b2127fcceceb4f200045657381311867529e6ba76c6b58e2825d
3x-ui-amd64-sha256=bfda7921984e7a72555c38d4e861c7a8b67b3686b658608937de99682f1452d0
3x-ui-arm64-sha256=a867c65ce52ad49f6504dd4c9c395337dc92ba57ae478da4f1b7ddb624014ed5
3x-ui-script-sha256=3c7dabe7ea9edf0d950eff1caffbf203a1f8b52e98fc75e203372757f47998c0
3x-ui-debian-service-sha256=6e120b037ed0a1305331e31de21471d3e6485ea28133ccf85a45d1270258ff9e
xray-install-commit=05aa47266c94e6fb5ca48cad96afdb4becb5891f
xray-installer-sha256=65e9e2accfc0b3b5dd24e7f2c552f35af3e581068155163d66d40f1d04fb6c95
xray-tag=v26.10.21
xray-amd64-sha256=0eec3ed4163c273d8c8bf821f7d219e257921cf584aa3fb970715e458e631978
xray-arm64-sha256=d1304946c486d98e6b886e9b931eaaaebb23835b30e217638037b7f2721d4e9c

View file

@ -0,0 +1,271 @@
#!/usr/bin/env bash
set -Eeuo pipefail
readonly XUI_TAG="v3.10.2"
readonly XUI_INSTALLER_SHA="9684bae4f769b2127fcceceb4f200045657381311867529e6ba76c6b58e2825d"
readonly XUI_SCRIPT_SHA="3c7dabe7ea9edf0d950eff1caffbf203a1f8b52e98fc75e203372757f47998c0"
readonly XUI_SERVICE_SHA="6e120b037ed0a1305331e31de21471d3e6485ea28133ccf85a45d1270258ff9e"
readonly XRAY_COMMIT="05aa47266c94e6fb5ca48cad96afdb4becb5891f"
readonly XRAY_INSTALLER_SHA="65e9e2accfc0b3b5dd24e7f2c552f35af3e581068155163d66d40f1d04fb6c95"
readonly XRAY_TAG="v26.10.21"
die() { printf '[x] %s\n' "$*" >&2; exit 1; }
architecture() {
case "$(uname -m)" in
x86_64|x64|amd64) printf 'amd64\n' ;;
armv8*|armv8|arm64|aarch64) printf 'arm64\n' ;;
*) die "Unsupported architecture for pinned installer payloads: $(uname -m)" ;;
esac
}
sha_for_arch() {
case "$1:$2" in
xui:amd64) printf '%s\n' bfda7921984e7a72555c38d4e861c7a8b67b3686b658608937de99682f1452d0 ;;
xui:arm64) printf '%s\n' a867c65ce52ad49f6504dd4c9c395337dc92ba57ae478da4f1b7ddb624014ed5 ;;
xray:amd64) printf '%s\n' 0eec3ed4163c273d8c8bf821f7d219e257921cf584aa3fb970715e458e631978 ;;
xray:arm64) printf '%s\n' d1304946c486d98e6b886e9b931eaaaebb23835b30e217638037b7f2721d4e9c ;;
*) die "Missing pinned digest for $1/$2" ;;
esac
}
fetch_pinned() {
local url="$1" dest="$2" expected_sha="$3" tmp
tmp="${dest}.partial.$$"
curl -fLsS --location --retry 2 --connect-timeout 15 --max-time 180 \
--proto '=https' --tlsv1.2 -o "$tmp" -- "$url" \
|| die "Pinned download failed: $(basename "$dest")"
[[ -s "$tmp" && "$(sha256sum "$tmp" | awk '{print $1}')" == "$expected_sha" ]] \
|| die "SHA-256 verification failed: $(basename "$dest")"
chmod 600 "$tmp"
mv -- "$tmp" "$dest"
}
validate_prepared_dir() {
local directory="$1"
[[ -d "$directory" && ! -L "$directory" \
&& "$(stat -c %u "$directory")" == 0 \
&& "$(stat -c %a "$directory")" =~ ^[0-7]*[0-5][0-5]$ ]] \
|| die "Prepared directory must be a root-owned directory not writable by group or others"
}
validate_prepared_file() {
local file="$1"
[[ -f "$file" && ! -L "$file" && "$(stat -c %u "$file")" == 0 \
&& "$(stat -c %a "$file")" =~ ^[0-7]*[0-5][0-5]$ ]] \
|| die "Prepared file is missing or writable by group/others: $(basename "$file")"
}
prepare() {
local source="$1" destination="$2" mirror_base="${3:-}" arch xui_arch xray_arch
local xui_sha xray_sha name expected
arch="$(architecture)"
case "$arch" in
amd64) xui_arch=amd64; xray_arch=64 ;;
arm64) xui_arch=arm64; xray_arch=arm64-v8a ;;
esac
case "$source" in
github)
[[ -z "$mirror_base" ]] || die "Do not pass a mirror base with github source"
;;
forgejo)
[[ "$mirror_base" =~ ^https://[A-Za-z0-9.-]+(:[0-9]+)?(/[A-Za-z0-9._/-]+)?$ \
&& "$mirror_base" != *..* ]] \
|| die "Forgejo mirror base must be a plain HTTPS URL without credentials, query, or traversal"
mirror_base="${mirror_base%/}"
;;
*) die "Usage: $0 prepare {github|forgejo} NEW_DEST_DIR [FORGEJO_HTTPS_BASE]" ;;
esac
[[ "$source" != forgejo || -n "$mirror_base" ]] || die "Forgejo HTTPS base is required"
[[ ! -e "$destination" && ! -L "$destination" ]] \
|| die "Destination must not already exist: $destination"
mkdir -m 700 -- "$destination"
destination="$(readlink -f -- "$destination")"
xui_sha="$(sha_for_arch xui "$arch")"
xray_sha="$(sha_for_arch xray "$arch")"
if [[ "$source" == github ]]; then
fetch_pinned "https://raw.githubusercontent.com/MHSanaei/3x-ui/${XUI_TAG}/install.sh" \
"$destination/xui-install.sh" "$XUI_INSTALLER_SHA"
fetch_pinned "https://github.com/MHSanaei/3x-ui/releases/download/${XUI_TAG}/x-ui-linux-${xui_arch}.tar.gz" \
"$destination/x-ui-linux-${xui_arch}.tar.gz" "$xui_sha"
fetch_pinned "https://raw.githubusercontent.com/MHSanaei/3x-ui/${XUI_TAG}/x-ui.sh" \
"$destination/x-ui.sh" "$XUI_SCRIPT_SHA"
fetch_pinned "https://raw.githubusercontent.com/MHSanaei/3x-ui/${XUI_TAG}/x-ui.service.debian" \
"$destination/x-ui.service.debian" "$XUI_SERVICE_SHA"
fetch_pinned "https://raw.githubusercontent.com/XTLS/Xray-install/${XRAY_COMMIT}/install-release.sh" \
"$destination/xray-install.sh" "$XRAY_INSTALLER_SHA"
fetch_pinned "https://github.com/XTLS/Xray-core/releases/download/${XRAY_TAG}/Xray-linux-${xray_arch}.zip" \
"$destination/Xray-linux-${xray_arch}.zip" "$xray_sha"
else
for name in xui-install.sh "x-ui-linux-${xui_arch}.tar.gz" x-ui.sh \
x-ui.service.debian xray-install.sh "Xray-linux-${xray_arch}.zip"; do
case "$name" in
xui-install.sh) expected="$XUI_INSTALLER_SHA" ;;
x-ui-linux-*.tar.gz) expected="$xui_sha" ;;
x-ui.sh) expected="$XUI_SCRIPT_SHA" ;;
x-ui.service.debian) expected="$XUI_SERVICE_SHA" ;;
xray-install.sh) expected="$XRAY_INSTALLER_SHA" ;;
Xray-linux-*.zip) expected="$xray_sha" ;;
esac
fetch_pinned "${mirror_base}/${name}" "$destination/$name" "$expected"
done
fi
printf '%s %s\n' "$xui_sha" "x-ui-linux-${xui_arch}.tar.gz" \
> "$destination/x-ui-linux-${xui_arch}.tar.gz.sha256"
printf 'SHA2-256= %s\n' "$xray_sha" \
> "$destination/Xray-linux-${xray_arch}.zip.dgst"
chmod 600 "$destination"/*
printf '[+] Verified pinned installers and release assets in %s\n' "$destination"
}
prepare_xray() {
local source="$1" destination="$2" mirror_base="${3:-}" arch xray_arch xray_sha
[[ ! -e "$destination" && ! -L "$destination" ]] \
|| die "Destination must not already exist: $destination"
arch="$(architecture)"
case "$arch" in
amd64) xray_arch=64 ;;
arm64) xray_arch=arm64-v8a ;;
esac
xray_sha="$(sha_for_arch xray "$arch")"
case "$source" in
github)
[[ -z "$mirror_base" ]] || die "Do not pass a mirror base with github source"
;;
forgejo)
[[ "$mirror_base" =~ ^https://[A-Za-z0-9.-]+(:[0-9]+)?(/[A-Za-z0-9._/-]+)?$ \
&& "$mirror_base" != *..* ]] \
|| die "Forgejo mirror base must be a plain HTTPS URL without credentials, query, or traversal"
mirror_base="${mirror_base%/}"
;;
*) die "Usage: $0 prepare-xray {github|forgejo} NEW_DEST_DIR [FORGEJO_HTTPS_BASE]" ;;
esac
[[ "$source" != forgejo || -n "$mirror_base" ]] || die "Forgejo HTTPS base is required"
mkdir -m 700 -- "$destination"
destination="$(readlink -f -- "$destination")"
if [[ "$source" == github ]]; then
fetch_pinned "https://raw.githubusercontent.com/XTLS/Xray-install/${XRAY_COMMIT}/install-release.sh" \
"$destination/xray-install.sh" "$XRAY_INSTALLER_SHA"
fetch_pinned "https://github.com/XTLS/Xray-core/releases/download/${XRAY_TAG}/Xray-linux-${xray_arch}.zip" \
"$destination/Xray-linux-${xray_arch}.zip" "$xray_sha"
else
fetch_pinned "${mirror_base}/xray-install.sh" "$destination/xray-install.sh" "$XRAY_INSTALLER_SHA"
fetch_pinned "${mirror_base}/Xray-linux-${xray_arch}.zip" \
"$destination/Xray-linux-${xray_arch}.zip" "$xray_sha"
fi
printf 'SHA2-256= %s\n' "$xray_sha" \
> "$destination/Xray-linux-${xray_arch}.zip.dgst"
chmod 600 "$destination"/*
printf '[+] Verified pinned Xray %s installer and payload in %s\n' "$XRAY_TAG" "$destination"
}
write_xui_curl_shim() {
local directory="$1" shim_dir="$2"
mkdir -m 700 -p -- "$shim_dir"
cat > "$shim_dir/curl" <<'CURL_SHIM'
#!/usr/bin/env bash
set -Eeuo pipefail
url=""
out=""
has_writeout=no
previous=""
for argument in "$@"; do
if [[ "$previous" == -o || "$previous" == --output \
|| "$previous" =~ ^-[A-Za-z]*o$ ]]; then out="$argument"; fi
[[ "$argument" != -w && "$argument" != --write-out ]] || has_writeout=yes
[[ "$argument" != http://* && "$argument" != https://* ]] || url="$argument"
previous="$argument"
done
answer_file=""
case "$url" in
https://github.com/MHSanaei/3x-ui/releases/download/v3.10.2/x-ui-linux-*.tar.gz)
answer_file="${SUPPLY_DIR}/$(basename "$url")" ;;
https://github.com/MHSanaei/3x-ui/releases/download/v3.10.2/x-ui-linux-*.tar.gz.sha256)
answer_file="${SUPPLY_DIR}/$(basename "$url")" ;;
https://raw.githubusercontent.com/MHSanaei/3x-ui/v3.10.2/x-ui.sh)
answer_file="${SUPPLY_DIR}/x-ui.sh" ;;
https://raw.githubusercontent.com/MHSanaei/3x-ui/v3.10.2/x-ui.service.debian)
answer_file="${SUPPLY_DIR}/x-ui.service.debian" ;;
https://raw.githubusercontent.com/MHSanaei/3x-ui/v3.10.2/x-ui.rc)
printf 'Unsupported platform data request\n' >&2; exit 1 ;;
https://check-host.net/ip)
[[ -x "${REAL_CURL_BIN:-}" ]] || { printf 'curl is unavailable for pinned data lookup\n' >&2; exit 1; }
exec "$REAL_CURL_BIN" "$@" --proto '=https' --tlsv1.2 --max-time 5 ;;
*) printf 'Blocked unpinned 3x-ui installer request: %s\n' "$url" >&2; exit 1 ;;
esac
[[ -r "$answer_file" ]] || { printf 'Pinned data file is missing: %s\n' "$answer_file" >&2; exit 1; }
if [[ -n "$out" && "$out" != /dev/null ]]; then
cp -- "$answer_file" "$out"
fi
if [[ "$has_writeout" == yes ]]; then printf '200'; fi
CURL_SHIM
chmod 700 "$shim_dir/curl"
: "$directory"
}
install_xui() {
local directory="$1" arch xui_arch shim_dir
directory="$(readlink -f -- "$directory")"
validate_prepared_dir "$directory"
arch="$(architecture)"
case "$arch" in amd64) xui_arch=amd64 ;; arm64) xui_arch=arm64 ;; esac
for file in xui-install.sh "x-ui-linux-${xui_arch}.tar.gz" \
"x-ui-linux-${xui_arch}.tar.gz.sha256" x-ui.sh x-ui.service.debian; do
validate_prepared_file "$directory/$file"
done
[[ "$(sha256sum "$directory/xui-install.sh" | awk '{print $1}')" == "$XUI_INSTALLER_SHA" \
&& "$(sha256sum "$directory/x-ui.sh" | awk '{print $1}')" == "$XUI_SCRIPT_SHA" \
&& "$(sha256sum "$directory/x-ui.service.debian" | awk '{print $1}')" == "$XUI_SERVICE_SHA" \
&& "$(sha256sum "$directory/x-ui-linux-${xui_arch}.tar.gz" | awk '{print $1}')" == "$(sha_for_arch xui "$arch")" ]] \
|| die "Prepared 3x-ui payload no longer matches the pins"
shim_dir="$(mktemp -d /tmp/ochenstarik-xui-curl.XXXXXX)"
trap 'rm -rf -- "$shim_dir"' EXIT
local real_curl
real_curl="$(command -v curl)"
[[ -x "$real_curl" ]] || die "curl is required for the pinned data lookup"
write_xui_curl_shim "$directory" "$shim_dir"
(cd -- "$directory" && SUPPLY_DIR="$directory" REAL_CURL_BIN="$real_curl" PATH="$shim_dir:$PATH" \
XUI_NONINTERACTIVE=1 bash ./xui-install.sh "$XUI_TAG")
rm -rf -- "$shim_dir"
trap - EXIT
}
install_xray() {
local directory="$1" arch xray_arch expected
directory="$(readlink -f -- "$directory")"
validate_prepared_dir "$directory"
arch="$(architecture)"
case "$arch" in amd64) xray_arch=64 ;; arm64) xray_arch=arm64-v8a ;; esac
expected="$(sha_for_arch xray "$arch")"
validate_prepared_file "$directory/xray-install.sh"
validate_prepared_file "$directory/Xray-linux-${xray_arch}.zip"
validate_prepared_file "$directory/Xray-linux-${xray_arch}.zip.dgst"
[[ "$(sha256sum "$directory/xray-install.sh" | awk '{print $1}')" == "$XRAY_INSTALLER_SHA" \
&& "$(sha256sum "$directory/Xray-linux-${xray_arch}.zip" | awk '{print $1}')" == "$expected" \
&& "$(awk -F '= ' '/^SHA2-256=/ {print $2}' "$directory/Xray-linux-${xray_arch}.zip.dgst")" == "$expected" ]] \
|| die "Prepared Xray payload no longer matches the pins"
(cd -- "$directory" && bash ./xray-install.sh install --local "Xray-linux-${xray_arch}.zip")
}
case "${1:-}" in
prepare)
[[ $# -ge 3 && $# -le 4 ]] || die "Usage: $0 prepare {github|forgejo} NEW_DEST_DIR [FORGEJO_HTTPS_BASE]"
prepare "$2" "$3" "${4:-}"
;;
prepare-xray)
[[ $# -ge 3 && $# -le 4 ]] || die "Usage: $0 prepare-xray {github|forgejo} NEW_DEST_DIR [FORGEJO_HTTPS_BASE]"
prepare_xray "$2" "$3" "${4:-}"
;;
install-xui)
[[ $# == 2 ]] || die "Usage: $0 install-xui PREPARED_DIR"
install_xui "$2"
;;
install-xray)
[[ $# == 2 ]] || die "Usage: $0 install-xray PREPARED_DIR"
install_xray "$2"
;;
*) die "Usage: $0 {prepare|prepare-xray|install-xui|install-xray} ..." ;;
esac

View file

@ -56,7 +56,7 @@ send_message() {
} }
[[ "$EUID" -eq 0 ]] || die "Запустите этот скрипт от имени root" [[ "$EUID" -eq 0 ]] || die "Запустите этот скрипт от имени root"
for command_name in curl sshd; do for command_name in curl sshd setsid; do
command -v "$command_name" >/dev/null 2>&1 || die "Не найдена команда: $command_name; сначала запустите второй скрипт" command -v "$command_name" >/dev/null 2>&1 || die "Не найдена команда: $command_name; сначала запустите второй скрипт"
done done
[[ -f "$PAM_SSHD" && ! -L "$PAM_SSHD" ]] || die "Не найден файл конфигурации PAM для SSH: $PAM_SSHD" [[ -f "$PAM_SSHD" && ! -L "$PAM_SSHD" ]] || die "Не найден файл конфигурации PAM для SSH: $PAM_SSHD"
@ -108,6 +108,12 @@ set -Euo pipefail
readonly CONFIG_FILE="/etc/ochenstarik-server/telegram.conf" readonly CONFIG_FILE="/etc/ochenstarik-server/telegram.conf"
readonly LOG_FILE="/var/log/ochenstarik-ssh-login-telegram.log" readonly LOG_FILE="/var/log/ochenstarik-ssh-login-telegram.log"
[[ "${PAM_TYPE:-}" == open_session ]] || exit 0
if [[ "${1:-}" != --worker ]]; then
/usr/bin/setsid --fork "$0" --worker \
</dev/null >/dev/null 2>&1 &
exit 0
fi
[[ -r "$CONFIG_FILE" ]] || exit 0 [[ -r "$CONFIG_FILE" ]] || exit 0
umask 077 umask 077

View file

@ -107,6 +107,52 @@ allow_ufw_rule() {
fi fi
} }
delete_ufw_rule_matches() {
local attempt
for attempt in {1..5}; do
ufw --force delete allow "$@" >/dev/null 2>&1 || break
done
}
retire_temporary_ssh_port_22() {
local managed_file="${1:-$MANAGED_PORTS_CONFIG}" answer temp_file ufw_status
[[ "$MANAGE_UFW" == yes && "$SSH_PORT" != 22 ]] || return 0
read -rp 'Успешно проверили вход по новому SSH-порту в отдельном терминале? Удалить временный порт 22? [y/N]: ' answer \
|| answer=""
[[ "$answer" == y || "$answer" == Y ]] || {
log "Правило порта 22 оставлено для безопасного доступа"
return 0
}
require_command ufw
delete_ufw_rule_matches 22/tcp
delete_ufw_rule_matches from 0.0.0.0/0 to any port 22 proto tcp
delete_ufw_rule_matches from ::/0 to any port 22 proto tcp
ufw_status="$(LANG=C ufw status)" \
|| die "Could not verify UFW after deleting temporary SSH port 22; keeping its managed record"
if grep -Eq '^22(/tcp)?([[:space:]]+\(v6\))?[[:space:]]+ALLOW([[:space:]]|$)' <<< "$ufw_status"; then
die "UFW still allows temporary SSH port 22; keeping its managed record"
fi
if [[ -e "$managed_file" ]]; then
[[ -f "$managed_file" && ! -L "$managed_file" ]] \
|| die "$managed_file must be a regular non-symlink file"
temp_file="$(mktemp "${managed_file}.XXXXXX")"
if grep -Fvx '22/tcp' "$managed_file" > "$temp_file"; then
:
elif [[ "$?" -ne 1 ]]; then
rm -f -- "$temp_file"
die "Could not update $managed_file"
fi
chown root:root "$temp_file"
chmod 600 "$temp_file"
mv -f -- "$temp_file" "$managed_file"
fi
log "Временный порт 22 удалён из UFW и списка управляемых правил"
}
select_action() { select_action() {
local choice local choice
@ -488,7 +534,6 @@ if [[ "$MANAGE_UFW" == yes ]]; then
log "Allowing SSH, HTTPS, and application ports in UFW without resetting existing rules" log "Allowing SSH, HTTPS, and application ports in UFW without resetting existing rules"
allow_ufw_rule "${SSH_PORT}/tcp" allow_ufw_rule "${SSH_PORT}/tcp"
allow_ufw_rule "443/tcp" allow_ufw_rule "443/tcp"
allow_ufw_rule "63636/tcp"
ufw --force enable ufw --force enable
else else
warn "MANAGE_UFW=no: firewall was not enabled or modified" warn "MANAGE_UFW=no: firewall was not enabled or modified"
@ -531,19 +576,5 @@ printf 'Password SSH authentication: %s\n' "$PASSWORD_AUTH"
printf 'Passwordless sudo: %s\n' "$PASSWORDLESS_SUDO" printf 'Passwordless sudo: %s\n' "$PASSWORDLESS_SUDO"
printf '\nOpen a NEW terminal and test before closing the current root session:\n' printf '\nOpen a NEW terminal and test before closing the current root session:\n'
printf 'ssh -p %s %s@<server-ip>\n' "$SSH_PORT" "$NEW_USERNAME" printf 'ssh -p %s %s@<server-ip>\n' "$SSH_PORT" "$NEW_USERNAME"
case "$(read_ip_mode)" in retire_temporary_ssh_port_22
ipv4)
printf 'After that login succeeds, remove temporary port 22 with:\n'
printf 'sudo ufw delete allow from 0.0.0.0/0 to any port 22 proto tcp\n'
;;
ipv6)
printf 'After that login succeeds, remove temporary port 22 with:\n'
printf 'sudo ufw delete allow from ::/0 to any port 22 proto tcp\n'
;;
both)
printf 'After that login succeeds, remove both temporary port 22 rules:\n'
printf 'sudo ufw delete allow from 0.0.0.0/0 to any port 22 proto tcp\n'
printf 'sudo ufw delete allow from ::/0 to any port 22 proto tcp\n'
;;
esac
printf 'To enable Telegram login notifications, run ochenstarik-server-tg-4.sh.\n' printf 'To enable Telegram login notifications, run ochenstarik-server-tg-4.sh.\n'

View file

@ -1,6 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -Eeuo pipefail set -Eeuo pipefail
readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
readonly XRAY_CONFIG="/usr/local/etc/xray/config.json" readonly XRAY_CONFIG="/usr/local/etc/xray/config.json"
readonly XRAY_DIR="/usr/local/etc/xray" readonly XRAY_DIR="/usr/local/etc/xray"
readonly STATE_DIR="/etc/ochenstarik-xray" readonly STATE_DIR="/etc/ochenstarik-xray"
@ -14,6 +15,45 @@ log() { printf '[+] %s\n' "$*"; }
warn() { printf '[!] %s\n' "$*" >&2; } warn() { printf '[!] %s\n' "$*" >&2; }
die() { printf '[x] %s\n' "$*" >&2; exit 1; } die() { printf '[x] %s\n' "$*" >&2; exit 1; }
choose_xray_source() {
local choice mirror_base
printf '\nИсточник установщика Xray и релиза v26.10.21:\n'
printf ' 1) GitHub (по умолчанию)\n'
printf ' 2) HTTPS-зеркало Forgejo\n'
while :; do
read -rp 'Выберите источник [1]: ' choice || die "Ввод прерван"
choice="${choice:-1}"
case "$choice" in
1) XRAY_SOURCE=github; XRAY_MIRROR_BASE=""; return 0 ;;
2)
read -rp 'Базовый HTTPS URL зеркала с проверенными файлами: ' mirror_base \
|| die "Ввод прерван"
XRAY_SOURCE=forgejo
XRAY_MIRROR_BASE="$mirror_base"
return 0
;;
*) warn "Введите 1 или 2" ;;
esac
done
}
fetch_subscription() {
local url="$1" output_file="$2" escaped_url LC_ALL=C
# curl's config parser accepts C-style escaping for backslash and quote.
# Reject control characters so the operator cannot add a second directive.
if [[ "$url" == *[[:cntrl:]]* ]]; then
die "Ссылка подписки содержит управляющие символы"
fi
escaped_url="${url//\\/\\\\}"
escaped_url="${escaped_url//\"/\\\"}"
{
printf 'url = "%s"\n' "$escaped_url"
printf 'fail\nsilent\nshow-error\nlocation\n'
printf 'proto = "=https"\ntlsv1.2\nmax-time = 30\n'
} | curl --config - -o "$output_file" \
|| die "Не удалось загрузить подписку"
}
TMP_DIR="" TMP_DIR=""
cleanup() { cleanup() {
[[ -z "$TMP_DIR" ]] || rm -rf -- "$TMP_DIR" [[ -z "$TMP_DIR" ]] || rm -rf -- "$TMP_DIR"
@ -175,17 +215,11 @@ LINKS_FILE="${TMP_DIR}/vless-links.txt"
LINK_FILE="${TMP_DIR}/vless-link.txt" LINK_FILE="${TMP_DIR}/vless-link.txt"
PORTS_FILE="${TMP_DIR}/vless-ports.txt" PORTS_FILE="${TMP_DIR}/vless-ports.txt"
GENERATED_CONFIG="${TMP_DIR}/config.json" GENERATED_CONFIG="${TMP_DIR}/config.json"
INSTALLER_FILE="${TMP_DIR}/install-release.sh"
if [[ "$SUBSCRIPTION_INPUT" == vless://* ]]; then if [[ "$SUBSCRIPTION_INPUT" == vless://* ]]; then
printf '%s' "$SUBSCRIPTION_INPUT" > "$RAW_FILE" printf '%s' "$SUBSCRIPTION_INPUT" > "$RAW_FILE"
else else
{ fetch_subscription "$SUBSCRIPTION_INPUT" "$RAW_FILE"
printf 'url = "%s"\n' "$SUBSCRIPTION_INPUT"
printf 'fail\nsilent\nshow-error\nlocation\n'
printf 'proto = "=https"\ntlsv1.2\nmax-time = 30\n'
} | curl --config - > "$RAW_FILE" \
|| die "Не удалось загрузить подписку"
fi fi
unset SUBSCRIPTION_INPUT unset SUBSCRIPTION_INPUT
chmod 600 "$RAW_FILE" chmod 600 "$RAW_FILE"
@ -398,13 +432,21 @@ valid_ipv4 "$VPN_IP" || die "Получен некорректный IPv4 VPN-с
chmod 600 "$GENERATED_CONFIG" chmod 600 "$GENERATED_CONFIG"
log "Подписка разобрана: VLESS + TCP/RAW + REALITY" log "Подписка разобрана: VLESS + TCP/RAW + REALITY"
log "Установка или обновление Xray из официального репозитория XTLS" log "Установка Xray v26.10.21 с проверкой закреплённых SHA-256"
curl --fail --silent --show-error --location \ choose_xray_source
--proto '=https' --tlsv1.2 \ SUPPLY_CHAIN_HELPER="${SCRIPT_DIR}/ochenstarik-server-supply-chain.sh"
https://raw.githubusercontent.com/XTLS/Xray-install/main/install-release.sh \ [[ -f "$SUPPLY_CHAIN_HELPER" && ! -L "$SUPPLY_CHAIN_HELPER" ]] \
-o "$INSTALLER_FILE" || die "Не найден локальный проверяющий загрузки: $SUPPLY_CHAIN_HELPER"
chmod 700 "$INSTALLER_FILE" XRAY_SUPPLY_DIR="${TMP_DIR}/xray-supply-chain"
bash "$INSTALLER_FILE" install if [[ "$XRAY_SOURCE" == github ]]; then
bash "$SUPPLY_CHAIN_HELPER" prepare-xray github "$XRAY_SUPPLY_DIR" \
|| die "Не удалось проверить файлы Xray из GitHub"
else
bash "$SUPPLY_CHAIN_HELPER" prepare-xray forgejo "$XRAY_SUPPLY_DIR" "$XRAY_MIRROR_BASE" \
|| die "Не удалось проверить файлы Xray из Forgejo-зеркала"
fi
bash "$SUPPLY_CHAIN_HELPER" install-xray "$XRAY_SUPPLY_DIR" \
|| die "Установка закреплённого Xray завершилась ошибкой"
command -v xray >/dev/null 2>&1 || die "Xray не установлен" command -v xray >/dev/null 2>&1 || die "Xray не установлен"
install -d -m 755 "$XRAY_DIR" /var/log/xray install -d -m 755 "$XRAY_DIR" /var/log/xray

View file

@ -0,0 +1,86 @@
#!/usr/bin/env bash
set -Eeuo pipefail
cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.."
tmp="$(mktemp -d)"
trap 'rm -rf -- "$tmp"' EXIT
awk '/^ cat > "\$BACKUP_RUNNER" <<.BACKUP_RUNNER_EOF/ { copying=1; next }
copying && /^BACKUP_RUNNER_EOF$/ { exit }
copying { print }' ochenstarik-server-backup-7.sh > "$tmp/runner"
awk '/^\[\[ "\$EUID"/ { exit } { print }' "$tmp/runner" > "$tmp/runner-functions"
bash -n "$tmp/runner-functions"
source "$tmp/runner-functions"
# Exercise the real create_backup early-return path with only the SSH transport
# replaced. This never connects to a host or reads the real filesystem.
protect_initial_files() { :; }
mkdir -p "$tmp/bin"
cat > "$tmp/bin/scp" <<'SCP_STUB'
#!/usr/bin/env bash
set -Eeuo pipefail
printf '%s\n' "$*" >> "$SCP_CALLS"
exit "${SCP_EXIT_CODE:-0}"
SCP_STUB
chmod +x "$tmp/bin/scp"
export PATH="$tmp/bin:$PATH" SCP_CALLS="$tmp/scp-calls"
BACKUP_ROOT="$tmp/encrypted-root"
BACKUP_ENCRYPTION=age
AGE_RECIPIENT='age1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq'
OFFSITE_TARGET='backup@example.test'
OFFSITE_PATH='/srv/backups'
OFFSITE_KNOWN_HOSTS="$tmp/known_hosts"
mkdir -p "$BACKUP_ROOT/initial"
touch "$OFFSITE_KNOWN_HOSTS"
archive="$BACKUP_ROOT/initial/initial-20261023-120000.tar.zst.age"
printf 'ciphertext fixture\n' > "$archive"
(
cd "$BACKUP_ROOT/initial"
sha256sum "$(basename "$archive")" > "$(basename "$archive").sha256"
)
checksum="${archive}.sha256"
# A failed first offsite upload must leave both immutable local files intact,
# return failure, and retry those exact files on the next invocation.
if (SCP_EXIT_CODE=42 create_backup initial 0) > "$tmp/failed.out" 2>&1; then
printf 'Initial offsite failure was accepted as success.\n' >&2
exit 1
fi
if grep -Fq 'Первичный снимок уже существует' "$tmp/failed.out"; then
printf 'Failed upload was reported as a successful existing backup.\n' >&2
exit 1
fi
[[ -f "$archive" && -f "$checksum" ]]
(
cd "$BACKUP_ROOT/initial"
sha256sum --check --status "$(basename "$checksum")"
)
[[ "$(wc -l < "$SCP_CALLS")" == 1 ]]
grep -Fq -- "$archive" "$SCP_CALLS"
grep -Fq -- "$checksum" "$SCP_CALLS"
create_backup initial 0 > "$tmp/retry.out"
grep -Fq 'Первичный снимок уже существует' "$tmp/retry.out"
[[ "$(wc -l < "$SCP_CALLS")" == 2 ]]
[[ "$(sha256sum "$archive" | awk '{print $1}')" == \
"$(cd "$BACKUP_ROOT/initial" && sha256sum --check --status "$(basename "$checksum")" && sha256sum "$(basename "$archive")" | awk '{print $1}')" ]]
# Selecting age cannot silently accept an existing plaintext initial snapshot.
plain_root="$tmp/plain-root"
mkdir -p "$plain_root/initial"
plain_archive="$plain_root/initial/initial-20261022-120000.tar.zst"
printf 'plaintext fixture\n' > "$plain_archive"
(
cd "$plain_root/initial"
sha256sum "$(basename "$plain_archive")" > "$(basename "$plain_archive").sha256"
)
BACKUP_ROOT="$plain_root"
if (create_backup initial 0) > "$tmp/plain.out" 2>&1; then
printf 'Age policy silently accepted an existing plaintext snapshot.\n' >&2
exit 1
fi
grep -Fq 'уже создан без шифрования' "$tmp/plain.out"
[[ -f "$plain_archive" && "$(wc -l < "$SCP_CALLS")" == 2 ]]
printf 'Initial backup retry and encryption-policy tests passed.\n'

132
tests/test-download-security.sh Executable file
View file

@ -0,0 +1,132 @@
#!/usr/bin/env bash
set -Eeuo pipefail
cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.."
tmp="$(mktemp -d)"
trap 'rm -rf -- "$tmp"' EXIT
mkdir -p "$tmp/bin"
# install-xui allocates the shim with mktemp -d before populating it. Exercise
# that real lifecycle, then both the cached and rejected download paths.
source <(awk '/^write_xui_curl_shim\(\)/ { copying=1 } copying { print } copying && /^}/ { exit }' \
ochenstarik-server-supply-chain.sh)
mkdir -m 700 "$tmp/shim" "$tmp/vendor"
printf 'verified-fixture\n' > "$tmp/vendor/x-ui.sh"
write_xui_curl_shim "$tmp/vendor" "$tmp/shim"
SUPPLY_DIR="$tmp/vendor" "$tmp/shim/curl" -fLRo "$tmp/copied" \
https://raw.githubusercontent.com/MHSanaei/3x-ui/v3.10.2/x-ui.sh
cmp "$tmp/vendor/x-ui.sh" "$tmp/copied"
if SUPPLY_DIR="$tmp/vendor" "$tmp/shim/curl" -o "$tmp/unknown" \
https://untrusted.example.test/installer.sh >/dev/null 2>&1; then
printf 'Shim accepted an unpinned installer request.\n' >&2
exit 1
fi
[[ ! -e "$tmp/unknown" ]]
# Load only the production URL fetcher so this test never calls the network or
# runs the VPN installer.
source <(awk '/^fetch_subscription\(\)/ { copying=1 } copying { print } copying && /^}/ { exit }' \
ochenstarik-server-vpn-5.sh)
die() { printf '%s\n' "$*" >&2; exit 1; }
source <(awk '/^choose_xray_source\(\)/ { copying=1 } copying { print } copying && /^}/ { exit }' \
ochenstarik-server-vpn-5.sh)
choose_xray_source <<< $'2\nhttps://git.example.test/project/releases/download/vendor-20261023'
[[ "$XRAY_SOURCE" == forgejo \
&& "$XRAY_MIRROR_BASE" == 'https://git.example.test/project/releases/download/vendor-20261023' ]]
cat > "$tmp/bin/curl" <<'CURL_STUB'
#!/usr/bin/env bash
set -Eeuo pipefail
printf '%s\n' "$*" > "$CURL_ARGS_LOG"
cat > "$CURL_CONFIG_LOG"
while (($#)); do
if [[ "$1" == -o ]]; then printf 'test payload\n' > "$2"; exit 0; fi
shift
done
exit 1
CURL_STUB
chmod +x "$tmp/bin/curl"
export PATH="$tmp/bin:$PATH"
export CURL_ARGS_LOG="$tmp/curl-args" CURL_CONFIG_LOG="$tmp/curl-config"
literal_url='https://subscriptions.example.test/path/a"b\c?key=literal-marker'
fetch_subscription "$literal_url" "$tmp/subscription"
[[ "$(cat "$tmp/subscription")" == 'test payload' ]]
grep -Fqx -- 'proto = "=https"' "$CURL_CONFIG_LOG"
grep -Fqx -- 'tlsv1.2' "$CURL_CONFIG_LOG"
grep -Fqx -- 'location' "$CURL_CONFIG_LOG"
expected_url="${literal_url//\\/\\\\}"
expected_url="${expected_url//\"/\\\"}"
grep -Fqx -- "url = \"$expected_url\"" "$CURL_CONFIG_LOG"
if grep -Fq 'literal-marker' "$CURL_ARGS_LOG"; then
printf 'Subscription URL leaked into curl arguments.\n' >&2
exit 1
fi
if (fetch_subscription $'https://subscriptions.example.test/good\nurl = "http://bad.example"' "$tmp/blocked") \
>/dev/null 2>&1; then
printf 'Control-character URL was accepted.\n' >&2
exit 1
fi
# A large value must not bypass rejection through grep -q / SIGPIPE under
# pipefail. Bash validates the entire value before curl sees any config.
printf -v padding '%131072s' ''
if (fetch_subscription $'https://subscriptions.example.test/\v'"${padding// /x}" "$tmp/blocked-large") \
>/dev/null 2>&1; then
printf 'Large control-character URL was accepted.\n' >&2
exit 1
fi
# S8 already carries these flags; lock the redirect policy in as a regression.
grep -Fq -- "--proto '=https' --tlsv1.2" ochenstarik-server-ai-agents-8.sh
# A failed Forgejo mirror request must stop without trying GitHub.
cat > "$tmp/bin/curl" <<'CURL_FAIL_STUB'
#!/usr/bin/env bash
set -Eeuo pipefail
printf '%s\n' "$*" >> "$CURL_ATTEMPTS"
exit 22
CURL_FAIL_STUB
chmod +x "$tmp/bin/curl"
export CURL_ATTEMPTS="$tmp/attempts"
if bash ./ochenstarik-server-supply-chain.sh prepare forgejo "$tmp/prepared" \
'https://git.example.test/project/releases/download/vendor-20261023' \
>/dev/null 2>&1; then
printf 'Failed Forgejo fetch unexpectedly succeeded.\n' >&2
exit 1
fi
[[ "$(wc -l < "$CURL_ATTEMPTS")" == 1 ]]
grep -Fq 'https://git.example.test/project/releases/download/vendor-20261023/xui-install.sh' "$CURL_ATTEMPTS"
if grep -Eq 'github\.com|raw\.githubusercontent\.com' "$CURL_ATTEMPTS"; then
printf 'Forgejo failure fell back to GitHub.\n' >&2
exit 1
fi
if bash ./ochenstarik-server-supply-chain.sh prepare-xray forgejo "$tmp/xray-prepared" \
'https://git.example.test/project/releases/download/vendor-20261023' \
>/dev/null 2>&1; then
printf 'Failed Forgejo Xray fetch unexpectedly succeeded.\n' >&2
exit 1
fi
[[ "$(wc -l < "$CURL_ATTEMPTS")" == 2 ]]
grep -Fq 'https://git.example.test/project/releases/download/vendor-20261023/xray-install.sh' "$CURL_ATTEMPTS"
if grep -Eq 'github\.com|raw\.githubusercontent\.com' "$CURL_ATTEMPTS"; then
printf 'Xray Forgejo failure fell back to GitHub.\n' >&2
exit 1
fi
# Both installer steps must reach their pinned helper paths and avoid mutable
# upstream main installer URLs.
grep -Fq 'prepare-xray github' ochenstarik-server-vpn-5.sh
grep -Fq 'prepare-xray forgejo' ochenstarik-server-vpn-5.sh
grep -Fq 'install-xray "$XRAY_SUPPLY_DIR"' ochenstarik-server-vpn-5.sh
grep -Fq 'prepare github' ochenstarik-server-panel-warp-6.sh
grep -Fq 'prepare forgejo' ochenstarik-server-panel-warp-6.sh
grep -Fq 'install-xui "${TMP_DIR}/vendor"' ochenstarik-server-panel-warp-6.sh
if grep -Eq 'XTLS/Xray-install/main|MHSanaei/3x-ui/main' \
ochenstarik-server-vpn-5.sh ochenstarik-server-panel-warp-6.sh; then
printf 'An installer step still references a mutable upstream main URL.\n' >&2
exit 1
fi
printf 'Download security tests passed.\n'

View file

@ -0,0 +1,295 @@
#!/usr/bin/env bash
set -Eeuo pipefail
cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.."
bash <<'STEP2_TEST'
set -Eeuo pipefail
source <(awk '/^\[\[ "\$EUID"/{exit} {print}' ochenstarik-server-2.sh)
SSH_PORT=20202
SSH_CONNECTION='198.51.100.9 41000 192.0.2.1 22'
MANAGED_RULES=()
declare -A RULE_SEEN=()
add_default_rules
[[ " ${MANAGED_RULES[*]} " == *' 20202/tcp '* ]]
[[ " ${MANAGED_RULES[*]} " == *' 22/tcp '* ]]
[[ " ${MANAGED_RULES[*]} " == *' 443/tcp '* ]]
[[ " ${MANAGED_RULES[*]} " != *' 80/tcp '* ]]
[[ " ${MANAGED_RULES[*]} " != *' 2096/tcp '* ]]
[[ " ${MANAGED_RULES[*]} " != *' 40000/tcp '* ]]
[[ " ${MANAGED_RULES[*]} " != *' 63636/tcp '* ]]
MANAGED_RULES=(22/tcp 20202/tcp 80/tcp 2096/tcp 443/tcp 40000/tcp 63636/tcp)
PREVIOUS_MANAGED_RULES=("${MANAGED_RULES[@]}")
RULE_SEEN=()
confirm_legacy_rules <<< 'n'
[[ " ${MANAGED_RULES[*]} " == *' 22/tcp '* ]]
[[ " ${MANAGED_RULES[*]} " == *' 443/tcp '* ]]
[[ " ${MANAGED_RULES[*]} " != *' 80/tcp '* ]]
[[ " ${MANAGED_RULES[*]} " != *' 2096/tcp '* ]]
[[ " ${MANAGED_RULES[*]} " != *' 40000/tcp '* ]]
[[ " ${MANAGED_RULES[*]} " != *' 63636/tcp '* ]]
legacy_delete_log="$(mktemp)"
ufw() {
if [[ "${1:-}" == status ]]; then
printf '%s\n' "${UFW_TEST_STATUS:-$'Status: active\nTo Action From'}"
return 0
fi
printf '%s\n' "$*" >> "$legacy_delete_log"
return 1
}
UFW_TEST_STATUS=$'Status: active\nTo Action From'
delete_managed_ufw_rules
for legacy_rule in 80/tcp 2096/tcp 40000/tcp 63636/tcp; do
grep -Fq -- "--force delete allow ${legacy_rule}" "$legacy_delete_log"
done
UFW_TEST_STATUS=$'Status: active\nTo Action From\n80/tcp (v6) ALLOW IN Anywhere (v6)'
if (delete_managed_ufw_rules) >/dev/null 2>&1; then
printf 'Step 2 accepted a remaining IPv6 legacy UFW allow rule\n' >&2
exit 1
fi
rm -f -- "$legacy_delete_log"
MANAGED_RULES=(80/tcp 443/tcp)
PREVIOUS_MANAGED_RULES=("${MANAGED_RULES[@]}")
RULE_SEEN=()
confirm_legacy_rules <<< 'y'
[[ " ${MANAGED_RULES[*]} " == *' 80/tcp '* ]]
[[ " ${MANAGED_RULES[*]} " == *' 443/tcp '* ]]
SSH_CONNECTION='198.51.100.9 41000 192.0.2.1 20202'
MANAGED_RULES=(20202/tcp 443/tcp)
RULE_SEEN=()
confirm_legacy_rules <<< 'n'
add_default_rules
[[ " ${MANAGED_RULES[*]} " != *' 22/tcp '* ]]
# sudo commonly clears SSH_CONNECTION. A first run should still retain the
# conventional SSH port if no sshd socket can be discovered, while a later
# run must not resurrect 22 after the managed list records its retirement.
unset SSH_CONNECTION
ss() { :; }
ssh_probe_dir="$(mktemp -d)"
trap 'rm -rf -- "$ssh_probe_dir"' EXIT
mapfile -t current_ssh_ports < <(discover_current_ssh_ports "$ssh_probe_dir/missing-managed-file")
[[ " ${current_ssh_ports[*]} " == *' 22 '* ]]
touch "$ssh_probe_dir/retired-managed-file"
mapfile -t current_ssh_ports < <(discover_current_ssh_ports "$ssh_probe_dir/retired-managed-file")
[[ " ${current_ssh_ports[*]} " != *' 22 '* ]]
STEP2_TEST
bash <<'PANEL_TEST'
set -Eeuo pipefail
source <(awk '/^\[\[ "\$EUID"/{exit} {print}' ochenstarik-server-panel-warp-6.sh)
OPENSSL_FAKE_VALUE=0123456789abcdef0123456789abcdef0123456789abcdef
openssl() { printf '%s' "$OPENSSL_FAKE_VALUE"; }
XUI_EXISTING_SUB_PATH=''
choose_public_access <<< ''
[[ "$XUI_PUBLIC_ACCESS" == no && "$XUI_WEB_LISTEN" == 127.0.0.1 ]]
choose_subscription_access <<< ''
[[ "$XUI_SUBSCRIPTION_ACCESS" == private && "$XUI_SUB_LISTEN" == 127.0.0.1 ]]
choose_subscription_access <<< '2'
[[ "$XUI_SUBSCRIPTION_ACCESS" == disabled ]]
choose_subscription_access <<< '3'
[[ "$XUI_SUBSCRIPTION_ACCESS" == public ]]
[[ "$XUI_SUB_LISTEN" == '' ]]
[[ "$XUI_SUB_PATH" =~ ^/[0-9a-f]{48}/$ ]]
subscription_db_probe="$(mktemp)"
SQLITE_TEST_SUB_PATH='/operator-custom/path/'
sqlite3() { printf '%s' "$SQLITE_TEST_SUB_PATH"; }
load_existing_subscription_path "$subscription_db_probe"
[[ "$XUI_EXISTING_SUB_PATH" == '/operator-custom/path/' ]]
choose_subscription_access <<< '1'
[[ "$XUI_SUB_PATH" == "$XUI_EXISTING_SUB_PATH" ]]
choose_subscription_access <<< '2'
[[ "$XUI_SUB_PATH" == "$XUI_EXISTING_SUB_PATH" ]]
XUI_EXISTING_SUB_PATH='/0123456789abcdef0123456789abcdef0123456789abcdef/'
choose_subscription_access <<< $'3\n'
[[ "$XUI_SUBSCRIPTION_ACCESS" == public && "$XUI_SUB_PATH" == "$XUI_EXISTING_SUB_PATH" ]]
OPENSSL_FAKE_VALUE=abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdef
choose_subscription_access <<< $'3\ny'
[[ "$XUI_SUBSCRIPTION_ACCESS" == public && "$XUI_SUB_PATH" == '/abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdef/' ]]
XUI_EXISTING_SUB_PATH='/operator-custom/path/'
choose_subscription_access <<< $'3\n1'
[[ "$XUI_SUBSCRIPTION_ACCESS" == private && "$XUI_SUB_PATH" == "$XUI_EXISTING_SUB_PATH" ]]
OPENSSL_FAKE_VALUE=abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdef
choose_subscription_access <<< $'3\ny'
[[ "$XUI_SUBSCRIPTION_ACCESS" == public && "$XUI_SUB_PATH" == '/abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdef/' ]]
rm -f -- "$subscription_db_probe"
XUI_EXISTING_SUB_PATH=''
choose_public_access <<< '1'
choose_subscription_access <<< '1'
set_xui_desired_listeners
[[ "$XUI_DESIRED_WEB_LISTEN" == 127.0.0.1 ]]
[[ "$XUI_DESIRED_SUB_LISTEN" == 127.0.0.1 ]]
[[ "$XUI_DESIRED_SUB_ENABLED" == true ]]
choose_public_access <<< '2'
choose_subscription_access <<< '3'
set_xui_desired_listeners
[[ "$XUI_DESIRED_WEB_LISTEN" == '' ]]
[[ "$XUI_DESIRED_SUB_LISTEN" == '' ]]
[[ "$XUI_DESIRED_SUB_ENABLED" == true ]]
choose_subscription_access <<< '2'
set_xui_desired_listeners
[[ "$XUI_DESIRED_SUB_LISTEN" == 127.0.0.1 ]]
[[ "$XUI_DESIRED_SUB_ENABLED" == false ]]
ss() {
case "${SS_MODE:-private}" in
private) printf 'LISTEN 0 4096 127.0.0.1:%s 0.0.0.0:*\n' "$SS_PORT" ;;
delayed)
local calls
calls="$(cat "$SS_CALLS_FILE")"
calls=$((calls + 1))
printf '%s\n' "$calls" > "$SS_CALLS_FILE"
((calls > 2)) && printf 'LISTEN 0 4096 127.0.0.1:%s 0.0.0.0:*\n' "$SS_PORT"
;;
wildcard) printf 'LISTEN 0 4096 0.0.0.0:%s 0.0.0.0:*\n' "$SS_PORT" ;;
ipv6) printf 'LISTEN 0 4096 [::]:%s [::]:*\n' "$SS_PORT" ;;
esac
}
SS_PORT=2053
SS_MODE=delayed
SS_CALLS_FILE="$(mktemp)"
printf '0\n' > "$SS_CALLS_FILE"
sleep() { :; }
wait_for_tcp_listener 2053 panel
[[ "$(cat "$SS_CALLS_FILE")" -eq 3 ]]
verify_private_listener 2053 panel
rm -f -- "$SS_CALLS_FILE"
SS_MODE=private
for SS_MODE in wildcard ipv6; do
if (verify_private_listener 2053 panel) >/dev/null 2>&1; then
printf 'Private listener check accepted %s bind\n' "$SS_MODE" >&2
exit 1
fi
done
guard_test_dir="$(mktemp -d)"
trap 'rm -rf -- "$guard_test_dir"' EXIT
cat > "$guard_test_dir/fake-x-ui" <<'FAKE_XUI'
#!/usr/bin/env bash
printf 'bind:%s\n' "$*" >> "$GUARD_LOG"
[[ "${XUI_FAKE_FAIL:-no}" != yes ]]
FAKE_XUI
cat > "$guard_test_dir/real-systemctl" <<'REAL_SYSTEMCTL'
#!/usr/bin/env bash
printf 'systemctl:%s\n' "$*" >> "$GUARD_LOG"
REAL_SYSTEMCTL
cat > "$guard_test_dir/real-rc-service" <<'REAL_RC_SERVICE'
#!/usr/bin/env bash
printf 'rc-service:%s\n' "$*" >> "$GUARD_LOG"
REAL_RC_SERVICE
cat > "$guard_test_dir/fake-sqlite3" <<'FAKE_SQLITE3'
#!/usr/bin/env bash
if [[ "$#" == 1 ]]; then
cat >/dev/null
printf 'sqlite-write\n' >> "$GUARD_LOG"
[[ "${SQLITE_FAKE_FAIL:-no}" != yes ]]
else
printf 'sqlite-query\n' >> "$GUARD_LOG"
printf '127.0.0.1|false\n'
fi
FAKE_SQLITE3
touch "$guard_test_dir/fake.db"
chmod 700 "$guard_test_dir/fake-x-ui" "$guard_test_dir/real-systemctl" \
"$guard_test_dir/real-rc-service" "$guard_test_dir/fake-sqlite3"
export GUARD_LOG="$guard_test_dir/calls.log"
create_xui_start_guard "$guard_test_dir/guard" "$guard_test_dir/released" \
"$guard_test_dir/fake-x-ui" "$guard_test_dir/fake.db" \
"$guard_test_dir/real-systemctl" "$guard_test_dir/real-rc-service" "$guard_test_dir/fake-sqlite3"
export PATH="${XUI_GUARD_DIR}:${PATH}"
systemctl start x-ui.service
[[ "$(sed -n '1p' "$GUARD_LOG")" == 'bind:setting -listenIP 127.0.0.1' ]]
[[ "$(sed -n '2p' "$GUARD_LOG")" == 'sqlite-write' ]]
[[ "$(tail -n1 "$GUARD_LOG")" == 'systemctl:start x-ui.service' ]]
: > "$GUARD_LOG"
export XUI_FAKE_FAIL=yes
if systemctl start x-ui; then
printf 'Guard allowed an x-ui start after bind failure\n' >&2
exit 1
fi
! grep -q '^systemctl:' "$GUARD_LOG"
unset XUI_FAKE_FAIL
: > "$GUARD_LOG"
export SQLITE_FAKE_FAIL=yes
if systemctl start x-ui; then
printf 'Guard allowed an x-ui start after safe subscription settings failed\n' >&2
exit 1
fi
! grep -q '^systemctl:' "$GUARD_LOG"
: > "$GUARD_LOG"
unset SQLITE_FAKE_FAIL
: > "$GUARD_LOG"
touch "$guard_test_dir/released"
systemctl start x-ui
[[ "$(wc -l < "$GUARD_LOG")" -eq 1 ]]
[[ "$(sed -n '1p' "$GUARD_LOG")" == 'systemctl:start x-ui' ]]
PANEL_TEST
bash <<'STEP3_TEST'
set -Eeuo pipefail
source <(awk '/^\[\[ "\$EUID"/{exit} {print}' ochenstarik-server-user-3.sh)
tmp_dir="$(mktemp -d)"
trap 'rm -rf -- "$tmp_dir"' EXIT
MANAGE_UFW=yes
SSH_PORT=20202
UFW_TEST_STATUS='Status: active'
ufw() {
if [[ "${1:-}" == status ]]; then
printf '%s\n' "$UFW_TEST_STATUS"
return 0
fi
printf '%s\n' "$*" >> "$tmp_dir/ufw-calls"
return 0
}
require_command() { :; }
chown() { :; }
printf '22/tcp\n20202/tcp\n443/tcp\n' > "$tmp_dir/managed"
retire_temporary_ssh_port_22 "$tmp_dir/managed" <<< 'n'
grep -Fqx '22/tcp' "$tmp_dir/managed"
retire_temporary_ssh_port_22 "$tmp_dir/managed" </dev/null
grep -Fqx '22/tcp' "$tmp_dir/managed"
retire_temporary_ssh_port_22 "$tmp_dir/managed" <<< 'y'
! grep -Fqx '22/tcp' "$tmp_dir/managed"
grep -Fqx '20202/tcp' "$tmp_dir/managed"
grep -Fq 'from 0.0.0.0/0 to any port 22 proto tcp' "$tmp_dir/ufw-calls"
grep -Fq 'from ::/0 to any port 22 proto tcp' "$tmp_dir/ufw-calls"
printf '22/tcp\n' > "$tmp_dir/managed"
MANAGE_UFW=no
retire_temporary_ssh_port_22 "$tmp_dir/managed" <<< 'y'
grep -Fqx '22/tcp' "$tmp_dir/managed"
MANAGE_UFW=yes
SSH_PORT=22
retire_temporary_ssh_port_22 "$tmp_dir/managed" <<< 'y'
grep -Fqx '22/tcp' "$tmp_dir/managed"
UFW_TEST_STATUS=$'Status: active\nTo Action From\n22/tcp ALLOW IN Anywhere'
SSH_PORT=20202
if (retire_temporary_ssh_port_22 "$tmp_dir/managed" <<< 'y') >/dev/null 2>&1; then
printf 'Step 3 reported success while UFW still allows port 22\n' >&2
exit 1
fi
grep -Fqx '22/tcp' "$tmp_dir/managed"
UFW_TEST_STATUS=$'Status: active\nTo Action From\n22/tcp (v6) ALLOW IN Anywhere (v6)'
if (retire_temporary_ssh_port_22 "$tmp_dir/managed" <<< 'y') >/dev/null 2>&1; then
printf 'Step 3 reported success while UFW still allows IPv6 port 22\n' >&2
exit 1
fi
grep -Fqx '22/tcp' "$tmp_dir/managed"
STEP3_TEST
if grep -q 'allow_ufw_rule "63636/tcp"' ochenstarik-server-user-3.sh; then
printf 'Step 3 must not open an unsolicited 63636/tcp rule\n' >&2
exit 1
fi
if grep -q 'allow_ufw_port 80 ' ochenstarik-server-panel-warp-6.sh; then
printf 'Step 6 must not open an unsolicited 80/tcp rule\n' >&2
exit 1
fi
grep -q 'XUI_PUBLIC_ACCESS=no' ochenstarik-server-panel-warp-6.sh
grep -q 'Приватный режим' ochenstarik-server-panel-warp-6.sh
grep -q 'WARP_ANSWER=no' ochenstarik-server-panel-warp-6.sh
printf '[+] Firewall and panel security behavior passed\n'

View file

@ -0,0 +1,119 @@
#!/usr/bin/env bash
set -Eeuo pipefail
cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.."
tmp="$(mktemp -d)"
trap 'rm -rf -- "$tmp"' EXIT
mkdir -p "$tmp/bin"
# Extract and run the exact hook template without writing to PAM or contacting
# Telegram. The stubs record invocation and deliberately make curl slow.
awk '/^cat > "\$NOTIFY_SCRIPT" <<.HOOK/ { copying=1; next }
copying && /^HOOK$/ { exit }
copying { print }' ochenstarik-server-tg-4.sh > "$tmp/notify-hook"
[[ -s "$tmp/notify-hook" ]]
# Relocate only the extracted fixture; production PAM paths are fixed and do
# not trust session-provided test overrides.
sed -i "s|/etc/ochenstarik-server/telegram.conf|$tmp/config|g; s|/var/log/ochenstarik-ssh-login-telegram.log|$tmp/notify.log|g; s|/usr/bin/setsid|$tmp/bin/setsid|g" "$tmp/notify-hook"
bash -n "$tmp/notify-hook"
chmod 700 "$tmp/notify-hook"
cat > "$tmp/config" <<'CONFIG'
TG_BOT_TOKEN=123456:secret-token-marker
TG_CHAT_ID=12345
SERVER_IP=192.0.2.1
CONFIG
chmod 600 "$tmp/config"
cat > "$tmp/bin/setsid" <<'SETSID_STUB'
#!/usr/bin/env bash
set -Eeuo pipefail
printf 'called\n' >> "$SETSID_LOG"
exec /usr/bin/setsid "$@"
SETSID_STUB
cat > "$tmp/bin/curl" <<'CURL_STUB'
#!/usr/bin/env bash
set -Eeuo pipefail
printf '%s\n' "$*" >> "$CURL_ARGS_LOG"
cat > "$CURL_CONFIG_LOG"
touch "$CURL_STARTED"
sleep 1
touch "$CURL_DONE"
printf '200\n' >&2
exit 0
CURL_STUB
chmod +x "$tmp/bin/setsid" "$tmp/bin/curl"
export PATH="$tmp/bin:$PATH"
export SETSID_LOG="$tmp/setsid.log" CURL_ARGS_LOG="$tmp/curl-args"
export CURL_CONFIG_LOG="$tmp/curl-config" CURL_STARTED="$tmp/curl-started"
export CURL_DONE="$tmp/curl-done"
export PAM_USER=test-user PAM_RHOST=192.0.2.7 PAM_SERVICE=sshd PAM_TTY=pts/1
PAM_TYPE=close_session "$tmp/notify-hook"
[[ ! -e "$SETSID_LOG" && ! -e "$CURL_STARTED" ]]
started_ns="$(date +%s%N)"
PAM_TYPE=open_session "$tmp/notify-hook"
elapsed_ms=$(( ($(date +%s%N) - started_ns) / 1000000 ))
((elapsed_ms < 500)) || { printf 'PAM hook waited %s ms for background work.\n' "$elapsed_ms" >&2; exit 1; }
for _ in {1..60}; do
[[ -e "$CURL_STARTED" ]] && break
sleep 0.05
done
[[ -e "$CURL_STARTED" ]]
[[ "$(wc -l < "$SETSID_LOG")" == 1 ]]
! grep -Fq 'secret-token-marker' "$CURL_ARGS_LOG"
grep -Fq 'url = "https://api.telegram.org/bot123456:secret-token-marker/sendMessage"' "$CURL_CONFIG_LOG"
# Verify the generated backup runner's opt-in encryption and strict offsite
# transport contract without invoking it against the host filesystem.
awk '/^ cat > "\$BACKUP_RUNNER" <<.BACKUP_RUNNER_EOF/ { copying=1; next }
copying && /^BACKUP_RUNNER_EOF$/ { exit }
copying { print }' ochenstarik-server-backup-7.sh > "$tmp/backup-runner"
bash -n "$tmp/backup-runner"
grep -Fq 'BACKUP_ENCRYPTION=none' "$tmp/backup-runner"
grep -Fq 'age --encrypt --recipient "$AGE_RECIPIENT" --output "$temporary"' "$tmp/backup-runner"
grep -Fq 'StrictHostKeyChecking=yes' "$tmp/backup-runner"
grep -Fq 'BatchMode=yes' "$tmp/backup-runner"
grep -Fq 'UserKnownHostsFile=$OFFSITE_KNOWN_HOSTS' "$tmp/backup-runner"
grep -Fq 'Выгрузка на заданный SSH-хост не удалась' "$tmp/backup-runner"
if grep -Fq 'ssh-keyscan' ochenstarik-server-backup-7.sh; then
printf 'Backup setup must not trust an unverified ssh-keyscan result.\n' >&2
exit 1
fi
awk '/^upload_offsite\(\)/ { copying=1 }
copying { print }
copying && /^}/ { exit }' "$tmp/backup-runner" > "$tmp/upload-function"
source "$tmp/upload-function"
log() { :; }
die() { printf '%s\n' "$*" >&2; exit 1; }
cat > "$tmp/bin/scp" <<'SCP_STUB'
#!/usr/bin/env bash
set -Eeuo pipefail
printf '%s\n' "$*" >> "$SCP_ARGS_LOG"
exit "${SCP_EXIT_CODE:-0}"
SCP_STUB
chmod +x "$tmp/bin/scp"
export SCP_ARGS_LOG="$tmp/scp-args"
OFFSITE_TARGET='backup@example.test'
OFFSITE_PATH='/srv/backups'
OFFSITE_KNOWN_HOSTS="$tmp/known_hosts"
touch "$OFFSITE_KNOWN_HOSTS" "$tmp/archive.tar.zst.age" "$tmp/archive.sha256"
SCP_EXIT_CODE=0 upload_offsite "$tmp/archive.tar.zst.age" "$tmp/archive.sha256"
grep -Fq -- '-o BatchMode=yes' "$SCP_ARGS_LOG"
grep -Fq -- '-o StrictHostKeyChecking=yes' "$SCP_ARGS_LOG"
grep -Fq -- "-o UserKnownHostsFile=$OFFSITE_KNOWN_HOSTS" "$SCP_ARGS_LOG"
if (SCP_EXIT_CODE=42 upload_offsite "$tmp/archive.tar.zst.age" "$tmp/archive.sha256") \
>/dev/null 2>&1; then
printf 'Offsite upload failure was accepted as success.\n' >&2
exit 1
fi
for _ in {1..60}; do
[[ -e "$CURL_DONE" ]] && break
sleep 0.05
done
[[ -e "$CURL_DONE" ]]
printf 'Notification and backup security tests passed.\n'